fix: avoid enumerating every public repository in issue search (#38992)

Both issue search endpoints resolve their repository filter with
`SearchRepositoryIDs` and pass the result to the indexer as `RepoIDs`.
They mean
to leave public repositories to the indexer, but
`SearchRepoOptions.AllPublic` is
only read when `OwnerID > 0`, so without an `owner` filter the flag does
nothing
and every public repository is enumerated, without a `LIMIT`, into
`repo_id IN (...)`.

Those IDs are redundant, as `allPublic` is passed to the indexer, which
already
matches every public repository. On a large instance this binds tens of
thousands
of parameters and can fail in the driver, making the endpoint return 500
for every
filter. Admins are worst hit, as `SearchRepositoryCondition` skips their
accessible-repository condition and enumerates the whole table.

Restrict the enumeration to private repositories. The result set is
unchanged, as
the dropped IDs are a subset of what `allPublic` matches.

Both endpoints held copies of this block, so it moves to
`routers/common`.

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Lunny Xiao
2026-08-20 09:29:54 -07:00
committed by GitHub
parent 943f026844
commit 475e51c7e0
5 changed files with 189 additions and 162 deletions
+62
View File
@@ -4,7 +4,13 @@
package common
import (
"context"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
)
func ParseIssueFilterStateIsClosed(state string) optional.Option[bool] {
@@ -23,3 +29,59 @@ func ParseIssueFilterStateIsClosed(state string) optional.Option[bool] {
func ParseIssueFilterTypeIsPull(typ string) optional.Option[bool] {
return optional.FromMapLookup(map[string]bool{"pulls": true, "issues": false}, typ)
}
type SearchIssuesRepoIDsOptions struct {
Doer *user_model.User
PublicOnly bool
OwnerName string
TeamName string
}
// SearchIssuesRepoIDs resolves the repository filter of an issue search. allPublic makes the indexer
// match everything its own is_public covers (modules/indexer/issues/util.go), so repoIDs omits those.
func SearchIssuesRepoIDs(ctx context.Context, opts SearchIssuesRepoIDsOptions) (repoIDs []int64, allPublic bool, err error) {
searchOpts := repo_model.SearchRepoOptions{
Private: opts.Doer != nil,
Collaborate: optional.None[bool](),
Actor: opts.Doer,
}
searchOpts.ApplyPublicOnly(opts.PublicOnly)
if opts.OwnerName != "" {
owner, err := user_model.GetUserByName(ctx, opts.OwnerName)
if err != nil {
return nil, false, err
}
searchOpts.OwnerID = owner.ID
searchOpts.Collaborate = optional.Some(false)
}
if opts.TeamName != "" {
if opts.OwnerName == "" {
return nil, false, util.NewInvalidArgumentErrorf("owner organisation is required for filtering on team")
}
team, err := organization.GetTeam(ctx, searchOpts.OwnerID, opts.TeamName)
if err != nil {
return nil, false, err
}
searchOpts.TeamID = team.ID
}
// SearchRepoOptions.AllPublic and AllLimited only apply under an owner filter, so the indexer covers them
allPublic = opts.OwnerName == ""
cond := repo_model.SearchRepositoryCondition(searchOpts)
if allPublic {
if !searchOpts.Private {
return []int64{0}, allPublic, nil // sees nothing beyond is_public, so skip the query
}
cond = cond.And(repo_model.NotPublicRepoUnderPublicOwnerCond()) // enumerating them scales with the instance
}
repoIDs, err = repo_model.SearchRepositoryIDsByCondition(ctx, cond)
if err != nil {
return nil, false, err
}
if len(repoIDs) == 0 {
// no repos found, don't let the indexer return all repos
repoIDs = []int64{0}
}
return repoIDs, allPublic, nil
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSearchIssuesRepoIDs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// the indexer's is_public covers repo 1 (public under a public owner) but misses repo 38 (public
// under a limited org) and repo 40 (public under a private org)
cases := []struct {
name string
doerID int64
opts SearchIssuesRepoIDsOptions
allPublic bool
want []int64
wantErr error
}{
{
name: "site admin", // admins skip the accessible repository condition entirely
doerID: 1,
allPublic: true,
want: []int64{2, 38, 40},
},
{
name: "regular user",
doerID: 2,
allPublic: true,
want: []int64{2, 38},
},
{
name: "private org member",
doerID: 5,
allPublic: true,
want: []int64{38, 40},
},
{
name: "anonymous",
allPublic: true,
want: []int64{0}, // the placeholder keeps the indexer off "every repository"
},
{
name: "public-only token",
doerID: 2,
opts: SearchIssuesRepoIDsOptions{PublicOnly: true},
allPublic: true,
want: []int64{0},
},
{
name: "owner filter", // turns allPublic off, so public repos must still be enumerated
doerID: 2,
opts: SearchIssuesRepoIDsOptions{OwnerName: "user2"},
want: []int64{1, 2},
},
{
name: "team without owner",
opts: SearchIssuesRepoIDsOptions{TeamName: "team1"},
wantErr: util.ErrInvalidArgument,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
opts := tc.opts
if tc.doerID != 0 {
opts.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID})
}
repoIDs, allPublic, err := SearchIssuesRepoIDs(t.Context(), opts)
if tc.wantErr != nil {
assert.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tc.allPublic, allPublic)
assert.Subset(t, repoIDs, tc.want)
if allPublic {
assert.NotContains(t, repoIDs, int64(1), "already matched by the indexer's is_public")
}
})
}
}