diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index c1733095cf941..ec9b4c36a073d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -250,50 +250,62 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) { } // public Only permission check - switch { - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public issues") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization): - if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") - return - } - if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser): - if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public users") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub): - if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { - ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification): - if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { - ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications") - return - } - case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage): - if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { - ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") - return + for _, category := range requiredScopeCategories { + switch category { + case auth_model.AccessTokenScopeCategoryRepository: + if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { + ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") + return + } + case auth_model.AccessTokenScopeCategoryIssue: + if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { + ctx.APIError(http.StatusForbidden, "token scope is limited to public issues") + return + } + case auth_model.AccessTokenScopeCategoryOrganization: + if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic { + ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") + return + } + if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic { + ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs") + return + } + case auth_model.AccessTokenScopeCategoryUser: + if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { + ctx.APIError(http.StatusForbidden, "token scope is limited to public users") + return + } + case auth_model.AccessTokenScopeCategoryActivityPub: + if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic { + ctx.APIError(http.StatusForbidden, "token scope is limited to public activitypub") + return + } + case auth_model.AccessTokenScopeCategoryNotification: + if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { + ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications") + return + } + case auth_model.AccessTokenScopeCategoryPackage: + if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { + ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") + return + } } } } } +func rejectPublicOnly() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.PublicOnly { + return + } + + ctx.APIError(http.StatusForbidden, "token scope is limited to public notifications") + } +} + // if a token is being used for auth, we check that it contains the required scope // if a token is not being used, reqToken will enforce other sign in methods func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { @@ -966,7 +978,9 @@ func Routes() *web.Router { m.Combo("/threads/{id}"). Get(reqToken(), notify.GetThread). Patch(reqToken(), notify.ReadThread) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification), rejectPublicOnly()) + // notifications API should not be used with public-only tokens, as notifications are mixed with both public and private repositories + // if a token is used with notifications API, it should be required to have the notification scope, and the token should not be public-only // Users (requires user scope) m.Group("/users", func() { @@ -1597,7 +1611,7 @@ func Routes() *web.Router { }, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) // Organizations - m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs) + m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), checkTokenPublicOnly(), org.ListMyOrgs) m.Group("/users/{username}/orgs", func() { m.Get("", reqToken(), org.ListUserOrgs) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index ce2a2e5580c92..af857d873d0c8 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -28,10 +28,14 @@ import ( func listUserOrgs(ctx *context.APIContext, u *user_model.User) { listOptions := utils.GetListOptions(ctx) + includeVisibility := organization.DoerViewOtherVisibility(ctx.Doer, u) + if ctx.PublicOnly { + includeVisibility = api.VisibleTypePublic + } opts := organization.FindOrgOptions{ ListOptions: listOptions, UserID: u.ID, - IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u), + IncludeVisibility: includeVisibility, } orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts) if err != nil { @@ -464,7 +468,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { // "$ref": "#/responses/notFound" includePrivate := false - if ctx.IsSigned { + if ctx.IsSigned && !ctx.PublicOnly { if ctx.Doer.IsAdmin { includePrivate = true } else { diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 4a5091fded26f..21233dc32dc3b 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -567,6 +567,10 @@ func GetByID(ctx *context.APIContext) { } return } + if ctx.PublicOnly && repo.IsPrivate { + ctx.APIError(http.StatusForbidden, "token scope is limited to public repos") + return + } permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer) if err != nil { diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index a664888dbf146..aded0c0c75b9e 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -18,6 +18,9 @@ import ( // listUserRepos - List the repositories owned by the given user. func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) { opts := utils.GetListOptions(ctx) + if ctx.PublicOnly { + private = false + } repos, count, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ Actor: u, @@ -79,7 +82,7 @@ func ListUserRepos(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - private := ctx.IsSigned + private := ctx.IsSigned && !ctx.PublicOnly listUserRepos(ctx, ctx.ContextUser, private) } @@ -103,11 +106,12 @@ func ListMyRepos(ctx *context.APIContext) { // "200": // "$ref": "#/responses/RepositoryList" + private := ctx.IsSigned && !ctx.PublicOnly opts := repo_model.SearchRepoOptions{ ListOptions: utils.GetListOptions(ctx), Actor: ctx.Doer, OwnerID: ctx.Doer.ID, - Private: ctx.IsSigned, + Private: private, IncludeDescription: true, } diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 50a54b2683eb9..f06f3c1d13a28 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -20,6 +20,9 @@ import ( // getStarredRepos returns the repos that the user with the specified userID has // starred func getStarredRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, error) { + if ctx.PublicOnly { + private = false + } starredRepos, err := repo_model.GetStarredRepos(ctx, &repo_model.StarredReposOptions{ ListOptions: utils.GetListOptions(ctx), StarrerID: user.ID, diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 005770c57132f..63fdebbd4c59f 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -203,7 +203,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) && !ctx.PublicOnly listOptions := utils.GetListOptions(ctx) opts := activities_model.GetFeedsOptions{ diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 9c11d5ca35d0b..8904b8429f279 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -18,6 +18,9 @@ import ( // getWatchedRepos returns the repos that the user with the specified userID is watching func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, int64, error) { + if ctx.PublicOnly { + private = false + } watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, &repo_model.WatchedReposOptions{ ListOptions: utils.GetListOptions(ctx), WatcherID: user.ID, diff --git a/tests/integration/api_notification_test.go b/tests/integration/api_notification_test.go index 0c17ece55d6d1..f1ac8d250d3ba 100644 --- a/tests/integration/api_notification_test.go +++ b/tests/integration/api_notification_test.go @@ -212,3 +212,23 @@ func TestAPINotificationPUT(t *testing.T) { assert.True(t, apiNL[0].Unread) assert.False(t, apiNL[0].Pinned) } + +func TestAPINotificationPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) + + token := getUserToken(t, user2.Name, auth_model.AccessTokenScopeReadNotification, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/notifications"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + req = NewRequest(t, "GET", "/api/v1/notifications/new"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", thread5.ID)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} diff --git a/tests/integration/api_public_only_test.go b/tests/integration/api_public_only_test.go new file mode 100644 index 0000000000000..b8ca992a1df92 --- /dev/null +++ b/tests/integration/api_public_only_test.go @@ -0,0 +1,96 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUserReposPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/repos"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + assert.NotEmpty(t, repos) + for _, repo := range repos { + assert.False(t, repo.Private) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") + + req = NewRequest(t, "GET", "/api/v1/users/user2/repos"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repos) + assert.NotEmpty(t, repos) + for _, repo := range repos { + assert.False(t, repo.Private) + } + assert.NotContains(t, repoNames(repos), "user2/repo2") +} + +func repoNames(repos []api.Repository) []string { + names := make([]string, 0, len(repos)) + for _, repo := range repos { + names = append(names, repo.FullName) + } + return names +} + +func TestAPIRepoByIDPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/repositories/1"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/api/v1/repositories/2"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIActivityFeedsPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser) + req := NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var activities []api.Activity + DecodeJSON(t, resp, &activities) + assert.NotEmpty(t, activities) + + publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", "/api/v1/users/user2/activities/feeds"). + AddTokenAuth(publicToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assert.Empty(t, activities) + + orgToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization) + req = NewRequest(t, "GET", "/api/v1/orgs/org3/activities/feeds"). + AddTokenAuth(orgToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assert.NotEmpty(t, activities) + + publicOrgToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", "/api/v1/orgs/org3/activities/feeds"). + AddTokenAuth(publicOrgToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &activities) + assert.Empty(t, activities) +} diff --git a/tests/integration/api_user_orgs_test.go b/tests/integration/api_user_orgs_test.go index 983d397b2b7b8..8911c6ebd3b36 100644 --- a/tests/integration/api_user_orgs_test.go +++ b/tests/integration/api_user_orgs_test.go @@ -155,3 +155,44 @@ func TestMyOrgs(t *testing.T) { }, }, orgs) } + +func TestMyOrgsPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + token := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/orgs"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var orgs []*api.Organization + DecodeJSON(t, resp, &orgs) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org17"}) + + assert.Equal(t, []*api.Organization{ + { + ID: 17, + Name: org17.Name, + UserName: org17.Name, + FullName: org17.FullName, + Email: org17.Email, + AvatarURL: org17.AvatarLink(t.Context()), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + { + ID: 3, + Name: org3.Name, + UserName: org3.Name, + FullName: org3.FullName, + Email: org3.Email, + AvatarURL: org3.AvatarLink(t.Context()), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + }, orgs) +} diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go index 989e7ab1d11ef..81780fcb4c829 100644 --- a/tests/integration/api_user_star_test.go +++ b/tests/integration/api_user_star_test.go @@ -155,3 +155,26 @@ func TestAPIStarDisabled(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) }) } + +func TestAPIStarPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly) + req := NewRequest(t, "GET", "/api/v1/user/starred"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + if assert.Len(t, repos, 1) { + assert.Equal(t, "user5/repo4", repos[0].FullName) + } + + req = NewRequest(t, "GET", "/api/v1/users/user2/starred"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repos) + if assert.Len(t, repos, 1) { + assert.Equal(t, "user5/repo4", repos[0].FullName) + } +}