Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 55 additions & 41 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions routers/api/v1/org/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions routers/api/v1/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions routers/api/v1/user/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

Expand All @@ -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,
}

Expand Down
3 changes: 3 additions & 0 deletions routers/api/v1/user/star.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion routers/api/v1/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
3 changes: 3 additions & 0 deletions routers/api/v1/user/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions tests/integration/api_notification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
96 changes: 96 additions & 0 deletions tests/integration/api_public_only_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
41 changes: 41 additions & 0 deletions tests/integration/api_user_orgs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading