From cf2d3324434847ce18d449c7fb93b02199ea9692 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 18 Nov 2024 20:08:32 -0800 Subject: [PATCH] Refactor find forks and fix possible bugs that weak permissions check (#32528) (#32547) Backport #32528 - Move models/GetForks to services/FindForks - Add doer as a parameter of FindForks to check permissions - Slight performance optimization for get forks API with batch loading of repository units - Add tests for forking repository to organizations --------- Co-authored-by: wxiaoguang --- models/repo/fork.go | 15 ------ models/repo/repo_list.go | 26 +++++++--- routers/api/v1/repo/fork.go | 15 ++++-- routers/web/repo/view.go | 24 ++++----- services/repository/fork.go | 24 +++++++++ templates/repo/forks.tmpl | 8 +-- tests/integration/api_fork_test.go | 80 +++++++++++++++++++++++++++++ tests/integration/repo_fork_test.go | 52 +++++++++++++++++++ 8 files changed, 203 insertions(+), 41 deletions(-) diff --git a/models/repo/fork.go b/models/repo/fork.go index 07cd31c269..1c75e86458 100644 --- a/models/repo/fork.go +++ b/models/repo/fork.go @@ -54,21 +54,6 @@ func GetUserFork(ctx context.Context, repoID, userID int64) (*Repository, error) return &forkedRepo, nil } -// GetForks returns all the forks of the repository -func GetForks(ctx context.Context, repo *Repository, listOptions db.ListOptions) ([]*Repository, error) { - sess := db.GetEngine(ctx) - - var forks []*Repository - if listOptions.Page == 0 { - forks = make([]*Repository, 0, repo.NumForks) - } else { - forks = make([]*Repository, 0, listOptions.PageSize) - sess = db.SetSessionPagination(sess, &listOptions) - } - - return forks, sess.Find(&forks, &Repository{ForkID: repo.ID}) -} - // IncrementRepoForkNum increment repository fork number func IncrementRepoForkNum(ctx context.Context, repoID int64) error { _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_forks=num_forks+1 WHERE id=?", repoID) diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index eacc98e222..0baf2edf58 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -98,8 +98,7 @@ func (repos RepositoryList) IDs() []int64 { return repoIDs } -// LoadAttributes loads the attributes for the given RepositoryList -func (repos RepositoryList) LoadAttributes(ctx context.Context) error { +func (repos RepositoryList) LoadOwners(ctx context.Context) error { if len(repos) == 0 { return nil } @@ -107,10 +106,6 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error { userIDs := container.FilterSlice(repos, func(repo *Repository) (int64, bool) { return repo.OwnerID, true }) - repoIDs := make([]int64, len(repos)) - for i := range repos { - repoIDs[i] = repos[i].ID - } // Load owners. users := make(map[int64]*user_model.User, len(userIDs)) @@ -123,12 +118,19 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error { for i := range repos { repos[i].Owner = users[repos[i].OwnerID] } + return nil +} + +func (repos RepositoryList) LoadLanguageStats(ctx context.Context) error { + if len(repos) == 0 { + return nil + } // Load primary language. stats := make(LanguageStatList, 0, len(repos)) if err := db.GetEngine(ctx). Where("`is_primary` = ? AND `language` != ?", true, "other"). - In("`repo_id`", repoIDs). + In("`repo_id`", repos.IDs()). Find(&stats); err != nil { return fmt.Errorf("find primary languages: %w", err) } @@ -141,10 +143,18 @@ func (repos RepositoryList) LoadAttributes(ctx context.Context) error { } } } - return nil } +// LoadAttributes loads the attributes for the given RepositoryList +func (repos RepositoryList) LoadAttributes(ctx context.Context) error { + if err := repos.LoadOwners(ctx); err != nil { + return err + } + + return repos.LoadLanguageStats(ctx) +} + // SearchRepoOptions holds the search options type SearchRepoOptions struct { db.ListOptions diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index a1e3c9804b..14a1a8d1c4 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -55,11 +55,20 @@ func ListForks(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx)) + forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx)) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetForks", err) + ctx.Error(http.StatusInternalServerError, "FindForks", err) return } + if err := repo_model.RepositoryList(forks).LoadOwners(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadOwners", err) + return + } + if err := repo_model.RepositoryList(forks).LoadUnits(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUnits", err) + return + } + apiForks := make([]*api.Repository, len(forks)) for i, fork := range forks { permission, err := access_model.GetUserRepoPermission(ctx, fork, ctx.Doer) @@ -70,7 +79,7 @@ func ListForks(ctx *context.APIContext) { apiForks[i] = convert.ToRepo(ctx, fork, permission) } - ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumForks)) + ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, apiForks) } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index da849d7b72..ad36177675 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -50,6 +50,7 @@ import ( "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" + repo_service "code.gitea.io/gitea/services/repository" files_service "code.gitea.io/gitea/services/repository/files" "github.com/nektos/act/pkg/model" @@ -1155,26 +1156,25 @@ func Forks(ctx *context.Context) { if page <= 0 { page = 1 } + pageSize := setting.ItemsPerPage - pager := context.NewPagination(ctx.Repo.Repository.NumForks, setting.ItemsPerPage, page, 5) - ctx.Data["Page"] = pager - - forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, db.ListOptions{ - Page: pager.Paginater.Current(), - PageSize: setting.ItemsPerPage, + forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, db.ListOptions{ + Page: page, + PageSize: pageSize, }) if err != nil { - ctx.ServerError("GetForks", err) + ctx.ServerError("FindForks", err) return } - for _, fork := range forks { - if err = fork.LoadOwner(ctx); err != nil { - ctx.ServerError("LoadOwner", err) - return - } + if err := repo_model.RepositoryList(forks).LoadOwners(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return } + pager := context.NewPagination(int(total), pageSize, page, 5) + ctx.Data["Page"] = pager + ctx.Data["Forks"] = forks ctx.HTML(http.StatusOK, tplForks) diff --git a/services/repository/fork.go b/services/repository/fork.go index f074fd1082..7adbffdb73 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" @@ -20,6 +21,8 @@ import ( "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" + + "xorm.io/builder" ) // ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error. @@ -244,3 +247,24 @@ func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Reposit return err } + +type findForksOptions struct { + db.ListOptions + RepoID int64 + Doer *user_model.User +} + +func (opts findForksOptions) ToConds() builder.Cond { + return builder.Eq{"fork_id": opts.RepoID}.And( + repo_model.AccessibleRepositoryCondition(opts.Doer, unit.TypeInvalid), + ) +} + +// FindForks returns all the forks of the repository +func FindForks(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, listOptions db.ListOptions) ([]*repo_model.Repository, int64, error) { + return db.FindAndCount[repo_model.Repository](ctx, findForksOptions{ + ListOptions: listOptions, + RepoID: repo.ID, + Doer: doer, + }) +} diff --git a/templates/repo/forks.tmpl b/templates/repo/forks.tmpl index 412c59b60e..725b67c651 100644 --- a/templates/repo/forks.tmpl +++ b/templates/repo/forks.tmpl @@ -5,12 +5,14 @@

{{ctx.Locale.Tr "repo.forks"}}

+
{{range .Forks}} -
- {{ctx.AvatarUtils.Avatar .Owner}} - {{.Owner.Name}} / {{.Name}} +
+ {{ctx.AvatarUtils.Avatar .Owner}} + {{.Owner.Name}} / {{.Name}}
{{end}} +
{{template "base/paginate" .}} diff --git a/tests/integration/api_fork_test.go b/tests/integration/api_fork_test.go index 7c231415a3..357dd27f86 100644 --- a/tests/integration/api_fork_test.go +++ b/tests/integration/api_fork_test.go @@ -7,8 +7,16 @@ import ( "net/http" "testing" + "code.gitea.io/gitea/models" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" ) func TestCreateForkNoLogin(t *testing.T) { @@ -16,3 +24,75 @@ func TestCreateForkNoLogin(t *testing.T) { req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{}) MakeRequest(t, req, http.StatusUnauthorized) } + +func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user1Sess := loginUser(t, "user1") + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + + // fork into a limited org + limitedOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) + assert.EqualValues(t, api.VisibleTypeLimited, limitedOrg.Visibility) + + ownerTeam1, err := org_model.OrgFromUser(limitedOrg).GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err) + assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam1, user1)) + user1Token := getTokenForLoggedInUser(t, user1Sess, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization) + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{ + Organization: &limitedOrg.Name, + }).AddTokenAuth(user1Token) + MakeRequest(t, req, http.StatusAccepted) + + // fork into a private org + user4Sess := loginUser(t, "user4") + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user4"}) + privateOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) + assert.EqualValues(t, api.VisibleTypePrivate, privateOrg.Visibility) + + ownerTeam2, err := org_model.OrgFromUser(privateOrg).GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err) + assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user4)) + user4Token := getTokenForLoggedInUser(t, user4Sess, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization) + req = NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{ + Organization: &privateOrg.Name, + }).AddTokenAuth(user4Token) + MakeRequest(t, req, http.StatusAccepted) + + t.Run("Anonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks") + resp := MakeRequest(t, req, http.StatusOK) + + var forks []*api.Repository + DecodeJSON(t, resp, &forks) + + assert.Empty(t, forks) + assert.EqualValues(t, "0", resp.Header().Get("X-Total-Count")) + }) + + t.Run("Logged in", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token) + resp := MakeRequest(t, req, http.StatusOK) + + var forks []*api.Repository + DecodeJSON(t, resp, &forks) + + assert.Len(t, forks, 1) + assert.EqualValues(t, "1", resp.Header().Get("X-Total-Count")) + + assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user1)) + + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token) + resp = MakeRequest(t, req, http.StatusOK) + + forks = []*api.Repository{} + DecodeJSON(t, resp, &forks) + + assert.Len(t, forks, 2) + assert.EqualValues(t, "2", resp.Header().Get("X-Total-Count")) + }) +} diff --git a/tests/integration/repo_fork_test.go b/tests/integration/repo_fork_test.go index feebebf062..52b55888b9 100644 --- a/tests/integration/repo_fork_test.go +++ b/tests/integration/repo_fork_test.go @@ -9,8 +9,12 @@ import ( "net/http/httptest" "testing" + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" @@ -74,3 +78,51 @@ func TestRepoForkToOrg(t *testing.T) { _, exists := htmlDoc.doc.Find(`a.ui.button[href*="/fork"]`).Attr("href") assert.False(t, exists, "Forking should not be allowed anymore") } + +func TestForkListLimitedAndPrivateRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + forkItemSelector := ".repo-fork-item" + + user1Sess := loginUser(t, "user1") + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + + // fork to a limited org + limitedOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) + assert.EqualValues(t, structs.VisibleTypeLimited, limitedOrg.Visibility) + ownerTeam1, err := org_model.OrgFromUser(limitedOrg).GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err) + assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam1, user1)) + testRepoFork(t, user1Sess, "user2", "repo1", limitedOrg.Name, "repo1", "") + + // fork to a private org + user4Sess := loginUser(t, "user4") + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user4"}) + privateOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) + assert.EqualValues(t, structs.VisibleTypePrivate, privateOrg.Visibility) + ownerTeam2, err := org_model.OrgFromUser(privateOrg).GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err) + assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user4)) + testRepoFork(t, user4Sess, "user2", "repo1", privateOrg.Name, "repo1", "") + + t.Run("Anonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", "/user2/repo1/forks") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.EqualValues(t, 0, htmlDoc.Find(forkItemSelector).Length()) + }) + + t.Run("Logged in", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/forks") + resp := user1Sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.EqualValues(t, 1, htmlDoc.Find(forkItemSelector).Length()) + + assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user1)) + resp = user1Sess.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + assert.EqualValues(t, 2, htmlDoc.Find(forkItemSelector).Length()) + }) +}