diff --git a/models/repo/repo.go b/models/repo/repo.go index 11442350dd..cdb30aa1a9 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -15,10 +15,12 @@ import ( "strconv" "strings" + auth_model "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/models/unit" user_model "forgejo.org/models/user" "forgejo.org/modules/cache" + "forgejo.org/modules/container" "forgejo.org/modules/git" "forgejo.org/modules/log" "forgejo.org/modules/markup" @@ -1001,3 +1003,55 @@ func UpdateRepoIssueNumbers(ctx context.Context, repoID int64, isPull, isClosed }) return nil } + +// Bulk load of all the repo_model.Repository objects for the repository resources that can be accessed by the given +// access tokens. Any access tokens which are not repository-specific tokens will not be present in the map. An +// optional filter function can be used to remove repositories (based upon a user visibility check, for example) before +// the map is constructed -- return `true` for repos to include. +func BulkGetRepositoriesForAccessTokens(ctx context.Context, tokens []*auth_model.AccessToken, filter func(*Repository) (bool, error)) (map[int64][]*Repository, error) { + // Load all the AccessTokenResourceRepo for the tokens that we're returning: + allRepoIDs := container.Set[int64]{} + repoResourcesByTokenID, err := auth_model.GetRepositoriesAccessibleWithTokens(ctx, tokens) + if err != nil { + return nil, fmt.Errorf("failed to fetch repositories for tokens: %w", err) + } + + // Load all the Repository models that are referenced by the AccessTokenResourceRepo's: + for _, repoResources := range repoResourcesByTokenID { + for _, repoResource := range repoResources { + allRepoIDs.Add(repoResource.RepoID) + } + } + reposByID, err := GetRepositoriesMapByIDs(ctx, allRepoIDs.Slice()) + if err != nil { + return nil, fmt.Errorf("failed to fetch repositories: %w", err) + } + + if filter != nil { + // Rebuild reposByID, filtering it with the provided filter function. It's more efficient to do this here, + // rather than returning the data and allowing the caller to filter it, because this guarantees one invocation + // per repository. `reposByTokenID` could have the same repository referenced by multiple access tokens. + tmp := reposByID + reposByID = make(map[int64]*Repository, len(tmp)) + for id, repo := range tmp { + if ok, err := filter(repo); err != nil { + return nil, fmt.Errorf("error filtering repo %d: %w", repo.ID, err) + } else if ok { + reposByID[id] = repo + } + } + } + + // Prepare a lookup map to access the repositories by token ID: + reposByTokenID := make(map[int64][]*Repository) + for tokenID, repoResources := range repoResourcesByTokenID { + for _, repoResource := range repoResources { + repo, ok := reposByID[repoResource.RepoID] + if ok { + reposByTokenID[tokenID] = append(reposByTokenID[tokenID], repo) + } + } + } + + return reposByTokenID, nil +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index d358fcc424..78aac13010 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -191,6 +191,7 @@ "settings.twofa_reenroll": "Re-enroll two-factor authentication", "settings.twofa_reenroll.description": "Re-enroll your two-factor authentication", "settings.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts.", + "settings.specific_repo_access": "Repository Access", "error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s", "avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels", "user.ghost.tooltip": "This user has been deleted, or cannot be matched.", diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index b223d35332..bc4f6eda98 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -18,7 +18,6 @@ import ( "forgejo.org/models/db" access_model "forgejo.org/models/perm/access" repo_model "forgejo.org/models/repo" - "forgejo.org/modules/container" "forgejo.org/modules/optional" api "forgejo.org/modules/structs" "forgejo.org/modules/web" @@ -66,42 +65,34 @@ func ListAccessTokens(ctx *context.APIContext) { } // Load all the AccessTokenResourceRepo for the tokens that we're returning: - allRepoIDs := container.Set[int64]{} - repoResourcesByTokenID, err := auth_model.GetRepositoriesAccessibleWithTokens(ctx, tokens) - if err != nil { - ctx.InternalServerError(err) - return - } - - // Load all the Repository models that are referenced by the AccessTokenResourceRepo's: - for _, repoResources := range repoResourcesByTokenID { - for _, repoResource := range repoResources { - allRepoIDs.Add(repoResource.RepoID) - } - } - reposByID, err := repo_model.GetRepositoriesMapByIDs(ctx, allRepoIDs.Slice()) - if err != nil { - ctx.InternalServerError(err) - return - } - - // Prepare a lookup map to access the repositories by token ID: - reposByTokenID := make(map[int64][]*api.RepositoryMeta) - for tokenID, repoResources := range repoResourcesByTokenID { - for _, repoResource := range repoResources { - repo, ok := reposByID[repoResource.RepoID] - if !ok { - // Shouldn't be possible with the foreign key on `AccessTokenResourceRepo` to the repository table. - ctx.Error(http.StatusInternalServerError, "reposById", "missing repository") - return + repoModelsByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens, + func(repo *repo_model.Repository) (bool, error) { + // Repos associated with a repo-specific access token should already be visible to the token owner, but it's + // possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that + // repo if so. + permission, err := access_model.GetUserRepoPermissionWithReducer(ctx, repo, ctx.Doer, ctx.Reducer) + if err != nil { + return false, err } - reposByTokenID[tokenID] = append(reposByTokenID[tokenID], &api.RepositoryMeta{ + return permission.HasAccess(), nil + }) + if err != nil { + ctx.InternalServerError(err) + return + } + // Convert map[int64]*Repository -> map[int64]*RepositoryMeta... + reposByTokenID := make(map[int64][]*api.RepositoryMeta) + for tokenID, repoModels := range repoModelsByTokenID { + repos := make([]*api.RepositoryMeta, len(repoModels)) + for i, repo := range repoModels { + repos[i] = &api.RepositoryMeta{ ID: repo.ID, Name: repo.Name, Owner: repo.OwnerName, FullName: repo.FullName(), - }) + } } + reposByTokenID[tokenID] = repos } apiTokens := make([]*api.AccessToken, len(tokens)) diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 359f0c1dce..a5c38b8dc3 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -9,6 +9,8 @@ import ( auth_model "forgejo.org/models/auth" "forgejo.org/models/db" + access_model "forgejo.org/models/perm/access" + repo_model "forgejo.org/models/repo" "forgejo.org/modules/base" "forgejo.org/modules/log" "forgejo.org/modules/setting" @@ -112,6 +114,11 @@ func RegenerateApplication(ctx *context.Context) { ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications") } +type TokenWithResources struct { + Token *auth_model.AccessToken + Repositories []*repo_model.Repository +} + func loadApplicationsData(ctx *context.Context) { ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) @@ -119,7 +126,33 @@ func loadApplicationsData(ctx *context.Context) { ctx.ServerError("ListAccessTokens", err) return } - ctx.Data["Tokens"] = tokens + + // Load all the AccessTokenResourceRepo for the tokens that we're returning: + reposByTokenID, err := repo_model.BulkGetRepositoriesForAccessTokens(ctx, tokens, + func(repo *repo_model.Repository) (bool, error) { + // Repos associated with a repo-specific access token should already be visible to the token owner, but it's + // possible that access has changed, such as a removed collaborator on a repo -- don't provide info on that + // repo if so. + permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + return false, err + } + return permission.HasAccess(), nil + }) + if err != nil { + ctx.ServerError("BulkGetRepositoriesForAccessTokens", err) + return + } + + tokensWithResources := make([]*TokenWithResources, len(tokens)) + for i := range tokens { + tokensWithResources[i] = &TokenWithResources{ + Token: tokens[i], + Repositories: reposByTokenID[tokens[i].ID], + } + } + + ctx.Data["TokensWithResources"] = tokensWithResources ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin if setting.OAuth2.Enabled { diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl index b77bcf067b..59d352eab3 100644 --- a/templates/user/settings/applications.tmpl +++ b/templates/user/settings/applications.tmpl @@ -8,27 +8,34 @@
- {{ctx.Locale.Tr "settings.repo_and_org_access"}}:
- {{if .DisplayPublicOnly}}
+ {{.Token.Name}}
+ {{if .Token.ResourceAllRepos}}
+
{{ctx.Locale.Tr "settings.repo_and_org_access"}}:
+ {{if .Token.DisplayPublicOnly}} {{ctx.Locale.Tr "settings.permissions_public_only"}} {{else}} {{ctx.Locale.Tr "settings.permissions_access_all"}} {{end}} - + {{else}} +{{ctx.Locale.Tr "settings.specific_repo_access"}}:
+{{ctx.Locale.Tr "settings.permissions_list"}}
{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}
+{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .Token.CreatedUnix)}} — {{svg "octicon-info"}} {{if .Token.HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .Token.UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}