loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(sec): permission check for project issue (#6843) (merge commit)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6843
Reviewed-by: 0ko <0ko@noreply.codeberg.org>

0ko 19f36391 94845020

+325 -43
+23
models/fixtures/PrivateIssueProjects/project.yml
··· 1 + - 2 + id: 1001 3 + title: Org project that contains private issues 4 + owner_id: 3 5 + repo_id: 0 6 + is_closed: false 7 + creator_id: 2 8 + board_type: 1 9 + type: 3 10 + created_unix: 1738000000 11 + updated_unix: 1738000000 12 + 13 + - 14 + id: 1002 15 + title: User project that contains private issues 16 + owner_id: 2 17 + repo_id: 0 18 + is_closed: false 19 + creator_id: 2 20 + board_type: 1 21 + type: 1 22 + created_unix: 1738000000 23 + updated_unix: 1738000000
+17
models/fixtures/PrivateIssueProjects/project_board.yml
··· 1 + - 2 + id: 1001 3 + project_id: 1001 4 + title: Triage 5 + creator_id: 2 6 + default: true 7 + created_unix: 1738000000 8 + updated_unix: 1738000000 9 + 10 + - 11 + id: 1002 12 + project_id: 1002 13 + title: Triage 14 + creator_id: 2 15 + default: true 16 + created_unix: 1738000000 17 + updated_unix: 1738000000
+11
models/fixtures/PrivateIssueProjects/project_issue.yml
··· 1 + - 2 + id: 1001 3 + issue_id: 6 4 + project_id: 1001 5 + project_board_id: 1001 6 + 7 + - 8 + id: 1002 9 + issue_id: 7 10 + project_id: 1002 11 + project_board_id: 1002
+7
models/fixtures/team_unit.yml
··· 1 1 - 2 2 id: 1 3 3 team_id: 1 4 + org_id: 3 4 5 type: 1 5 6 access_mode: 4 6 7 7 8 - 8 9 id: 2 9 10 team_id: 1 11 + org_id: 3 10 12 type: 2 11 13 access_mode: 4 12 14 13 15 - 14 16 id: 3 15 17 team_id: 1 18 + org_id: 3 16 19 type: 3 17 20 access_mode: 4 18 21 19 22 - 20 23 id: 4 21 24 team_id: 1 25 + org_id: 3 22 26 type: 4 23 27 access_mode: 4 24 28 25 29 - 26 30 id: 5 27 31 team_id: 1 32 + org_id: 3 28 33 type: 5 29 34 access_mode: 4 30 35 31 36 - 32 37 id: 6 33 38 team_id: 1 39 + org_id: 3 34 40 type: 6 35 41 access_mode: 4 36 42 37 43 - 38 44 id: 7 39 45 team_id: 1 46 + org_id: 3 40 47 type: 7 41 48 access_mode: 4 42 49
+52 -10
models/issues/issue_project.go
··· 7 7 "context" 8 8 9 9 "code.gitea.io/gitea/models/db" 10 + org_model "code.gitea.io/gitea/models/organization" 10 11 project_model "code.gitea.io/gitea/models/project" 11 12 user_model "code.gitea.io/gitea/models/user" 13 + "code.gitea.io/gitea/modules/optional" 12 14 "code.gitea.io/gitea/modules/util" 13 15 ) 14 16 ··· 48 50 } 49 51 50 52 // LoadIssuesFromColumn load issues assigned to this column 51 - func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column) (IssueList, error) { 52 - issueList, err := Issues(ctx, &IssuesOptions{ 53 + func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, doer *user_model.User, org *org_model.Organization, isClosed optional.Option[bool]) (IssueList, error) { 54 + issueOpts := &IssuesOptions{ 53 55 ProjectColumnID: b.ID, 54 56 ProjectID: b.ProjectID, 55 57 SortType: "project-column-sorting", 56 - }) 58 + IsClosed: isClosed, 59 + } 60 + if doer != nil { 61 + issueOpts.User = doer 62 + issueOpts.Org = org 63 + } else { 64 + issueOpts.AllPublic = true 65 + } 66 + 67 + issueList, err := Issues(ctx, issueOpts) 57 68 if err != nil { 58 69 return nil, err 59 70 } 60 71 61 72 if b.Default { 62 - issues, err := Issues(ctx, &IssuesOptions{ 63 - ProjectColumnID: db.NoConditionID, 64 - ProjectID: b.ProjectID, 65 - SortType: "project-column-sorting", 66 - }) 73 + issueOpts.ProjectColumnID = db.NoConditionID 74 + 75 + issues, err := Issues(ctx, issueOpts) 67 76 if err != nil { 68 77 return nil, err 69 78 } ··· 78 87 } 79 88 80 89 // LoadIssuesFromColumnList load issues assigned to the columns 81 - func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList) (map[int64]IssueList, error) { 90 + func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList, doer *user_model.User, org *org_model.Organization, isClosed optional.Option[bool]) (map[int64]IssueList, error) { 82 91 issuesMap := make(map[int64]IssueList, len(bs)) 83 92 for i := range bs { 84 - il, err := LoadIssuesFromColumn(ctx, bs[i]) 93 + il, err := LoadIssuesFromColumn(ctx, bs[i], doer, org, isClosed) 85 94 if err != nil { 86 95 return nil, err 87 96 } ··· 160 169 }) 161 170 }) 162 171 } 172 + 173 + // NumIssuesInProjects returns the amount of issues assigned to one of the project 174 + // in the list which the doer can access. 175 + func NumIssuesInProjects(ctx context.Context, pl []*project_model.Project, doer *user_model.User, org *org_model.Organization, isClosed optional.Option[bool]) (map[int64]int, error) { 176 + numMap := make(map[int64]int, len(pl)) 177 + for _, p := range pl { 178 + num, err := NumIssuesInProject(ctx, p, doer, org, isClosed) 179 + if err != nil { 180 + return nil, err 181 + } 182 + numMap[p.ID] = num 183 + } 184 + 185 + return numMap, nil 186 + } 187 + 188 + // NumIssuesInProject returns the amount of issues assigned to the project which 189 + // the doer can access. 190 + func NumIssuesInProject(ctx context.Context, p *project_model.Project, doer *user_model.User, org *org_model.Organization, isClosed optional.Option[bool]) (int, error) { 191 + numIssuesInProject := int(0) 192 + bs, err := p.GetColumns(ctx) 193 + if err != nil { 194 + return 0, err 195 + } 196 + im, err := LoadIssuesFromColumnList(ctx, bs, doer, org, isClosed) 197 + if err != nil { 198 + return 0, err 199 + } 200 + for _, il := range im { 201 + numIssuesInProject += len(il) 202 + } 203 + return numIssuesInProject, nil 204 + }
+100
models/issues/issue_project_test.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: GPL-3.0-or-later 3 + 4 + package issues_test 5 + 6 + import ( 7 + "testing" 8 + 9 + "code.gitea.io/gitea/models/db" 10 + "code.gitea.io/gitea/models/issues" 11 + "code.gitea.io/gitea/models/organization" 12 + "code.gitea.io/gitea/models/project" 13 + "code.gitea.io/gitea/models/unittest" 14 + user_model "code.gitea.io/gitea/models/user" 15 + "code.gitea.io/gitea/modules/optional" 16 + "code.gitea.io/gitea/tests" 17 + 18 + "github.com/stretchr/testify/assert" 19 + "github.com/stretchr/testify/require" 20 + ) 21 + 22 + func TestPrivateIssueProjects(t *testing.T) { 23 + defer tests.AddFixtures("models/fixtures/PrivateIssueProjects/")() 24 + require.NoError(t, unittest.PrepareTestDatabase()) 25 + 26 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 27 + t.Run("Organization project", func(t *testing.T) { 28 + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) 29 + orgProject := unittest.AssertExistsAndLoadBean(t, &project.Project{ID: 1001, OwnerID: org.ID}) 30 + column := unittest.AssertExistsAndLoadBean(t, &project.Column{ID: 1001, ProjectID: orgProject.ID}) 31 + 32 + t.Run("Authenticated user", func(t *testing.T) { 33 + defer tests.PrintCurrentTest(t)() 34 + issueList, err := issues.LoadIssuesFromColumn(db.DefaultContext, column, user2, org, optional.None[bool]()) 35 + require.NoError(t, err) 36 + assert.Len(t, issueList, 1) 37 + assert.EqualValues(t, 6, issueList[0].ID) 38 + 39 + issuesNum, err := issues.NumIssuesInProject(db.DefaultContext, orgProject, user2, org, optional.None[bool]()) 40 + require.NoError(t, err) 41 + assert.EqualValues(t, 1, issuesNum) 42 + 43 + issuesNum, err = issues.NumIssuesInProject(db.DefaultContext, orgProject, user2, org, optional.Some(true)) 44 + require.NoError(t, err) 45 + assert.EqualValues(t, 0, issuesNum) 46 + 47 + issuesNum, err = issues.NumIssuesInProject(db.DefaultContext, orgProject, user2, org, optional.Some(false)) 48 + require.NoError(t, err) 49 + assert.EqualValues(t, 1, issuesNum) 50 + }) 51 + 52 + t.Run("Anonymous user", func(t *testing.T) { 53 + defer tests.PrintCurrentTest(t)() 54 + issueList, err := issues.LoadIssuesFromColumn(db.DefaultContext, column, nil, org, optional.None[bool]()) 55 + require.NoError(t, err) 56 + assert.Empty(t, issueList) 57 + 58 + issuesNum, err := issues.NumIssuesInProject(db.DefaultContext, orgProject, nil, org, optional.None[bool]()) 59 + require.NoError(t, err) 60 + assert.EqualValues(t, 0, issuesNum) 61 + }) 62 + }) 63 + 64 + t.Run("User project", func(t *testing.T) { 65 + userProject := unittest.AssertExistsAndLoadBean(t, &project.Project{ID: 1002, OwnerID: user2.ID}) 66 + column := unittest.AssertExistsAndLoadBean(t, &project.Column{ID: 1002, ProjectID: userProject.ID}) 67 + 68 + t.Run("Authenticated user", func(t *testing.T) { 69 + defer tests.PrintCurrentTest(t)() 70 + issueList, err := issues.LoadIssuesFromColumn(db.DefaultContext, column, user2, nil, optional.None[bool]()) 71 + require.NoError(t, err) 72 + assert.Len(t, issueList, 1) 73 + assert.EqualValues(t, 7, issueList[0].ID) 74 + 75 + issuesNum, err := issues.NumIssuesInProject(db.DefaultContext, userProject, user2, nil, optional.None[bool]()) 76 + require.NoError(t, err) 77 + assert.EqualValues(t, 1, issuesNum) 78 + 79 + issuesNum, err = issues.NumIssuesInProject(db.DefaultContext, userProject, user2, nil, optional.Some(true)) 80 + require.NoError(t, err) 81 + assert.EqualValues(t, 0, issuesNum) 82 + 83 + issuesNum, err = issues.NumIssuesInProject(db.DefaultContext, userProject, user2, nil, optional.Some(false)) 84 + require.NoError(t, err) 85 + assert.EqualValues(t, 1, issuesNum) 86 + }) 87 + 88 + t.Run("Anonymous user", func(t *testing.T) { 89 + defer tests.PrintCurrentTest(t)() 90 + 91 + issueList, err := issues.LoadIssuesFromColumn(db.DefaultContext, column, nil, nil, optional.None[bool]()) 92 + require.NoError(t, err) 93 + assert.Empty(t, issueList) 94 + 95 + issuesNum, err := issues.NumIssuesInProject(db.DefaultContext, userProject, nil, nil, optional.None[bool]()) 96 + require.NoError(t, err) 97 + assert.EqualValues(t, 0, issuesNum) 98 + }) 99 + }) 100 + }
-14
models/project/column.go
··· 57 57 return "project_board" // TODO: the legacy table name should be project_column 58 58 } 59 59 60 - // NumIssues return counter of all issues assigned to the column 61 - func (c *Column) NumIssues(ctx context.Context) int { 62 - total, err := db.GetEngine(ctx).Table("project_issue"). 63 - Where("project_id=?", c.ProjectID). 64 - And("project_board_id=?", c.ID). 65 - GroupBy("issue_id"). 66 - Cols("issue_id"). 67 - Count() 68 - if err != nil { 69 - return 0 70 - } 71 - return int(total) 72 - } 73 - 74 60 func (c *Column) GetIssues(ctx context.Context) ([]*ProjectIssue, error) { 75 61 issues := make([]*ProjectIssue, 0, 5) 76 62 if err := db.GetEngine(ctx).Where("project_id=?", c.ProjectID).
-14
models/project/issue.go
··· 34 34 return err 35 35 } 36 36 37 - // NumIssues return counter of all issues assigned to a project 38 - func (p *Project) NumIssues(ctx context.Context) int { 39 - c, err := db.GetEngine(ctx).Table("project_issue"). 40 - Where("project_id=?", p.ID). 41 - GroupBy("issue_id"). 42 - Cols("issue_id"). 43 - Count() 44 - if err != nil { 45 - log.Error("NumIssues: %v", err) 46 - return 0 47 - } 48 - return int(c) 49 - } 50 - 51 37 // NumClosedIssues return counter of closed issues assigned to a project 52 38 func (p *Project) NumClosedIssues(ctx context.Context) int { 53 39 c, err := db.GetEngine(ctx).Table("project_issue").
+14 -1
routers/web/org/projects.go
··· 126 126 ctx.Data["PageIsViewProjects"] = true 127 127 ctx.Data["SortType"] = sortType 128 128 129 + numOpenIssues, err := issues_model.NumIssuesInProjects(ctx, projects, ctx.Doer, ctx.Org.Organization, optional.Some(false)) 130 + if err != nil { 131 + ctx.ServerError("NumIssuesInProjects", err) 132 + return 133 + } 134 + numClosedIssues, err := issues_model.NumIssuesInProjects(ctx, projects, ctx.Doer, ctx.Org.Organization, optional.Some(true)) 135 + if err != nil { 136 + ctx.ServerError("NumIssuesInProjects", err) 137 + return 138 + } 139 + ctx.Data["NumOpenIssuesInProject"] = numOpenIssues 140 + ctx.Data["NumClosedIssuesInProject"] = numClosedIssues 141 + 129 142 ctx.HTML(http.StatusOK, tplProjects) 130 143 } 131 144 ··· 332 345 return 333 346 } 334 347 335 - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) 348 + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, ctx.Doer, ctx.Org.Organization, optional.None[bool]()) 336 349 if err != nil { 337 350 ctx.ServerError("LoadIssuesOfColumns", err) 338 351 return
+14 -1
routers/web/repo/projects.go
··· 125 125 ctx.Data["IsProjectsPage"] = true 126 126 ctx.Data["SortType"] = sortType 127 127 128 + numOpenIssues, err := issues_model.NumIssuesInProjects(ctx, projects, ctx.Doer, ctx.Org.Organization, optional.Some(false)) 129 + if err != nil { 130 + ctx.ServerError("NumIssuesInProjects", err) 131 + return 132 + } 133 + numClosedIssues, err := issues_model.NumIssuesInProjects(ctx, projects, ctx.Doer, ctx.Org.Organization, optional.Some(true)) 134 + if err != nil { 135 + ctx.ServerError("NumIssuesInProjects", err) 136 + return 137 + } 138 + ctx.Data["NumOpenIssuesInProject"] = numOpenIssues 139 + ctx.Data["NumClosedIssuesInProject"] = numClosedIssues 140 + 128 141 ctx.HTML(http.StatusOK, tplProjects) 129 142 } 130 143 ··· 310 323 return 311 324 } 312 325 313 - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) 326 + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, ctx.Doer, nil, optional.None[bool]()) 314 327 if err != nil { 315 328 ctx.ServerError("LoadIssuesOfColumns", err) 316 329 return
+2 -2
templates/projects/list.tmpl
··· 49 49 <div class="group"> 50 50 <div class="flex-text-block"> 51 51 {{svg "octicon-issue-opened" 14}} 52 - {{ctx.Locale.PrettyNumber (.NumOpenIssues ctx)}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}} 52 + {{ctx.Locale.PrettyNumber (index $.NumOpenIssuesInProject .ID)}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}} 53 53 </div> 54 54 <div class="flex-text-block"> 55 55 {{svg "octicon-check" 14}} 56 - {{ctx.Locale.PrettyNumber (.NumClosedIssues ctx)}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}} 56 + {{ctx.Locale.PrettyNumber (index $.NumClosedIssuesInProject .ID)}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}} 57 57 </div> 58 58 </div> 59 59 {{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
+1 -1
templates/projects/view.tmpl
··· 70 70 <div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}"> 71 71 <div class="ui large label project-column-title tw-py-1"> 72 72 <div class="ui small circular grey label project-column-issue-count"> 73 - {{.NumIssues ctx}} 73 + {{len (index $.IssuesMap .ID)}} 74 74 </div> 75 75 <span class="project-column-title-label">{{.Title}}</span> 76 76 </div>
+84
tests/integration/private_project_test.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: GPL-3.0-or-later 3 + 4 + package integration 5 + 6 + import ( 7 + "net/http" 8 + "strings" 9 + "testing" 10 + 11 + org_model "code.gitea.io/gitea/models/organization" 12 + project_model "code.gitea.io/gitea/models/project" 13 + "code.gitea.io/gitea/models/unittest" 14 + user_model "code.gitea.io/gitea/models/user" 15 + "code.gitea.io/gitea/tests" 16 + 17 + "github.com/stretchr/testify/assert" 18 + ) 19 + 20 + func TestPrivateIssueProject(t *testing.T) { 21 + defer tests.AddFixtures("models/fixtures/PrivateIssueProjects/")() 22 + defer tests.PrepareTestEnv(t)() 23 + 24 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 25 + sess := loginUser(t, user2.Name) 26 + 27 + test := func(t *testing.T, sess *TestSession, username string, projectID int64, hasAccess bool) { 28 + t.Helper() 29 + defer tests.PrintCurrentTest(t, 1)() 30 + 31 + // Test that the projects overview page shows the correct open and close issues. 32 + req := NewRequestf(t, "GET", "%s/-/projects", username) 33 + resp := sess.MakeRequest(t, req, http.StatusOK) 34 + 35 + htmlDoc := NewHTMLParser(t, resp.Body) 36 + openCloseStats := htmlDoc.Find(".milestone-toolbar .group").First().Text() 37 + if hasAccess { 38 + assert.Contains(t, openCloseStats, "1\u00a0Open") 39 + } else { 40 + assert.Contains(t, openCloseStats, "0\u00a0Open") 41 + } 42 + assert.Contains(t, openCloseStats, "0\u00a0Closed") 43 + 44 + // Check that on the project itself the issue is not shown. 45 + req = NewRequestf(t, "GET", "%s/-/projects/%d", username, projectID) 46 + resp = sess.MakeRequest(t, req, http.StatusOK) 47 + 48 + htmlDoc = NewHTMLParser(t, resp.Body) 49 + htmlDoc.AssertElement(t, ".project-column .issue-card", hasAccess) 50 + 51 + // And that the issue count is correct. 52 + issueCount := strings.TrimSpace(htmlDoc.Find(".project-column-issue-count").Text()) 53 + if hasAccess { 54 + assert.EqualValues(t, "1", issueCount) 55 + } else { 56 + assert.EqualValues(t, "0", issueCount) 57 + } 58 + } 59 + 60 + t.Run("Organization project", func(t *testing.T) { 61 + org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 3}) 62 + orgProject := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1001, OwnerID: org.ID}) 63 + 64 + t.Run("Authenticated user", func(t *testing.T) { 65 + test(t, sess, org.Name, orgProject.ID, true) 66 + }) 67 + 68 + t.Run("Anonymous user", func(t *testing.T) { 69 + test(t, emptyTestSession(t), org.Name, orgProject.ID, false) 70 + }) 71 + }) 72 + 73 + t.Run("User project", func(t *testing.T) { 74 + userProject := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1002, OwnerID: user2.ID}) 75 + 76 + t.Run("Authenticated user", func(t *testing.T) { 77 + test(t, sess, user2.Name, userProject.ID, true) 78 + }) 79 + 80 + t.Run("Anonymous user", func(t *testing.T) { 81 + test(t, emptyTestSession(t), user2.Name, userProject.ID, false) 82 + }) 83 + }) 84 + }