mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-22 10:13:32 +00:00
Backport #38992 by @lunny 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: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user