[FEAT] Repository flags

This implements "repository flags", a way for instance administrators to
assign custom flags to repositories. The idea is that custom templates
can look at these flags, and display banners based on them, Forgejo does
not provide anything built on top of it, just the foundation. The
feature is optional, and disabled by default. To enable it, set
`[repository].ENABLE_FLAGS = true`.

On the UI side, instance administrators will see a new "Manage flags"
tab on repositories, and a list of enabled tags (if any) on the
repository home page. The "Manage flags" page allows them to remove
existing flags, or add any new ones that are listed in
`[repository].SETTABLE_FLAGS`.

The model does not enforce that only the `SETTABLE_FLAGS` are present.
If the setting is changed, old flags may remain present in the database,
and anything that uses them, will still work. The repository flag
management page will allow an instance administrator to remove them, but
not set them, once removed.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
(cherry picked from commit ba735ce222)
(cherry picked from commit f09f6e029b)
(cherry picked from commit 2f8b041489)
(cherry picked from commit d3186ee5f4)
This commit is contained in:
Gergely Nagy 2024-01-04 14:28:19 +01:00 committed by Earl Warren
parent 9809f96a4a
commit 36f7c162e2
No known key found for this signature in database
GPG Key ID: 0579CB2928A78A00
14 changed files with 604 additions and 0 deletions

View File

@ -46,6 +46,8 @@ var migrations = []*Migration{
NewMigration("create the forgejo_auth_token table", forgejo_v1_20.CreateAuthorizationTokenTable),
// v3 -> v4
NewMigration("Add default_permissions to repo_unit", forgejo_v1_22.AddDefaultPermissionsToRepoUnit),
// v4 -> v5
NewMigration("create the forgejo_repo_flag table", forgejo_v1_22.CreateRepoFlagTable),
}
// GetCurrentDBVersion returns the current Forgejo database version.

View File

@ -0,0 +1,22 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_22 //nolint
import (
"xorm.io/xorm"
)
type RepoFlag struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
Name string `xorm:"UNIQUE(s) INDEX"`
}
func (RepoFlag) TableName() string {
return "forgejo_repo_flag"
}
func CreateRepoFlagTable(x *xorm.Engine) error {
return x.Sync(new(RepoFlag))
}

102
models/repo/repo_flags.go Normal file
View File

@ -0,0 +1,102 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"context"
"code.gitea.io/gitea/models/db"
"xorm.io/builder"
)
// RepoFlag represents a single flag against a repository
type RepoFlag struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
Name string `xorm:"UNIQUE(s) INDEX"`
}
func init() {
db.RegisterModel(new(RepoFlag))
}
// TableName provides the real table name
func (RepoFlag) TableName() string {
return "forgejo_repo_flag"
}
// ListFlags returns the array of flags on the repo.
func (repo *Repository) ListFlags(ctx context.Context) ([]RepoFlag, error) {
var flags []RepoFlag
err := db.GetEngine(ctx).Table(&RepoFlag{}).Where("repo_id = ?", repo.ID).Find(&flags)
if err != nil {
return nil, err
}
return flags, nil
}
// IsFlagged returns whether a repo has any flags or not
func (repo *Repository) IsFlagged(ctx context.Context) bool {
has, _ := db.Exist[RepoFlag](ctx, builder.Eq{"repo_id": repo.ID})
return has
}
// GetFlag returns a single RepoFlag based on its name
func (repo *Repository) GetFlag(ctx context.Context, flagName string) (bool, *RepoFlag, error) {
flag, has, err := db.Get[RepoFlag](ctx, builder.Eq{"repo_id": repo.ID, "name": flagName})
if err != nil {
return false, nil, err
}
return has, flag, nil
}
// HasFlag returns true if a repo has a given flag, false otherwise
func (repo *Repository) HasFlag(ctx context.Context, flagName string) bool {
has, _ := db.Exist[RepoFlag](ctx, builder.Eq{"repo_id": repo.ID, "name": flagName})
return has
}
// AddFlag adds a new flag to the repo
func (repo *Repository) AddFlag(ctx context.Context, flagName string) error {
return db.Insert(ctx, RepoFlag{
RepoID: repo.ID,
Name: flagName,
})
}
// DeleteFlag removes a flag from the repo
func (repo *Repository) DeleteFlag(ctx context.Context, flagName string) (int64, error) {
return db.DeleteByBean(ctx, &RepoFlag{RepoID: repo.ID, Name: flagName})
}
// ReplaceAllFlags replaces all flags of a repo with a new set
func (repo *Repository) ReplaceAllFlags(ctx context.Context, flagNames []string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err := db.DeleteBeans(ctx, &RepoFlag{RepoID: repo.ID}); err != nil {
return err
}
if len(flagNames) == 0 {
return committer.Commit()
}
var flags []RepoFlag
for _, name := range flagNames {
flags = append(flags, RepoFlag{
RepoID: repo.ID,
Name: name,
})
}
if err := db.Insert(ctx, &flags); err != nil {
return err
}
return committer.Commit()
}

View File

@ -0,0 +1,114 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package repo_test
import (
"testing"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestRepositoryFlags(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
// ********************
// ** NEGATIVE TESTS **
// ********************
// Unless we add flags, the repo has none
flags, err := repo.ListFlags(db.DefaultContext)
assert.NoError(t, err)
assert.Empty(t, flags)
// If the repo has no flags, it is not flagged
flagged := repo.IsFlagged(db.DefaultContext)
assert.False(t, flagged)
// Trying to find a flag when there is none
has := repo.HasFlag(db.DefaultContext, "foo")
assert.False(t, has)
// Trying to retrieve a non-existent flag indicates not found
has, _, err = repo.GetFlag(db.DefaultContext, "foo")
assert.NoError(t, err)
assert.False(t, has)
// Deleting a non-existent flag fails
deleted, err := repo.DeleteFlag(db.DefaultContext, "no-such-flag")
assert.NoError(t, err)
assert.Equal(t, int64(0), deleted)
// ********************
// ** POSITIVE TESTS **
// ********************
// Adding a flag works
err = repo.AddFlag(db.DefaultContext, "foo")
assert.NoError(t, err)
// Adding it again fails
err = repo.AddFlag(db.DefaultContext, "foo")
assert.Error(t, err)
// Listing flags includes the one we added
flags, err = repo.ListFlags(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, flags, 1)
assert.Equal(t, "foo", flags[0].Name)
// With a flag added, the repo is flagged
flagged = repo.IsFlagged(db.DefaultContext)
assert.True(t, flagged)
// The flag can be found
has = repo.HasFlag(db.DefaultContext, "foo")
assert.True(t, has)
// Added flag can be retrieved
_, flag, err := repo.GetFlag(db.DefaultContext, "foo")
assert.NoError(t, err)
assert.Equal(t, "foo", flag.Name)
// Deleting a flag works
deleted, err = repo.DeleteFlag(db.DefaultContext, "foo")
assert.NoError(t, err)
assert.Equal(t, int64(1), deleted)
// The list is now empty
flags, err = repo.ListFlags(db.DefaultContext)
assert.NoError(t, err)
assert.Empty(t, flags)
// Replacing an empty list works
err = repo.ReplaceAllFlags(db.DefaultContext, []string{"bar"})
assert.NoError(t, err)
// The repo is now flagged with "bar"
has = repo.HasFlag(db.DefaultContext, "bar")
assert.True(t, has)
// Replacing a tag set with another works
err = repo.ReplaceAllFlags(db.DefaultContext, []string{"baz", "quux"})
assert.NoError(t, err)
// The repo now has two tags
flags, err = repo.ListFlags(db.DefaultContext)
assert.NoError(t, err)
assert.Len(t, flags, 2)
assert.Equal(t, "baz", flags[0].Name)
assert.Equal(t, "quux", flags[1].Name)
// Replacing flags with an empty set deletes all flags
err = repo.ReplaceAllFlags(db.DefaultContext, []string{})
assert.NoError(t, err)
// The repo is now unflagged
flagged = repo.IsFlagged(db.DefaultContext)
assert.False(t, flagged)
}

View File

@ -113,6 +113,9 @@ var (
Wiki []string
DefaultTrustModel string
} `ini:"repository.signing"`
SettableFlags []string
EnableFlags bool
}{
DetectedCharsetsOrder: []string{
"UTF-8",
@ -270,6 +273,8 @@ var (
Wiki: []string{"never"},
DefaultTrustModel: "collaborator",
},
EnableFlags: false,
}
RepoRootPath string
ScriptType = "bash"
@ -372,4 +377,6 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
log.Error("Unrecognised repository download or clone method: %s", method)
}
}
Repository.EnableFlags = sec.Key("ENABLE_FLAGS").MustBool()
}

View File

@ -96,6 +96,9 @@ func NewFuncMap() template.FuncMap {
"AppDomain": func() string { // documented in mail-templates.md
return setting.Domain
},
"RepoFlagsEnabled": func() bool {
return setting.Repository.EnableFlags
},
"AssetVersion": func() string {
return setting.AssetVersion
},

View File

@ -0,0 +1,49 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"net/http"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
const (
tplRepoFlags base.TplName = "repo/flags"
)
func Manage(ctx *context.Context) {
ctx.Data["IsRepoFlagsPage"] = true
ctx.Data["Title"] = ctx.Tr("repo.admin.manage_flags")
flags := map[string]bool{}
for _, f := range setting.Repository.SettableFlags {
flags[f] = false
}
repoFlags, _ := ctx.Repo.Repository.ListFlags(ctx)
for _, f := range repoFlags {
flags[f.Name] = true
}
ctx.Data["Flags"] = flags
ctx.HTML(http.StatusOK, tplRepoFlags)
}
func ManagePost(ctx *context.Context) {
newFlags := ctx.FormStrings("flags")
err := ctx.Repo.Repository.ReplaceAllFlags(ctx, newFlags)
if err != nil {
ctx.Flash.Error(ctx.Tr("repo.admin.failed_to_replace_flags"))
log.Error("Error replacing repository flags for repo %d: %v", ctx.Repo.Repository.ID, err)
} else {
ctx.Flash.Success(ctx.Tr("repo.admin.flags_replaced"))
}
ctx.Redirect(ctx.Repo.Repository.HTMLURL() + "/flags")
}

View File

@ -38,6 +38,7 @@ import (
"code.gitea.io/gitea/routers/web/repo"
"code.gitea.io/gitea/routers/web/repo/actions"
"code.gitea.io/gitea/routers/web/repo/badges"
repo_flags "code.gitea.io/gitea/routers/web/repo/flags"
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
"code.gitea.io/gitea/routers/web/user"
user_setting "code.gitea.io/gitea/routers/web/user/setting"
@ -1574,6 +1575,13 @@ func registerRoutes(m *web.Route) {
gitHTTPRouters(m)
})
})
if setting.Repository.EnableFlags {
m.Group("/{username}/{reponame}/flags", func() {
m.Get("", repo_flags.Manage)
m.Post("", repo_flags.ManagePost)
}, adminReq, context.RepoAssignment, context.UnitTypes())
}
// ***** END: Repository *****
m.Group("/notifications", func() {

View File

View File

@ -0,0 +1,8 @@
{{if .Repository.IsFlagged $.Context}}
<div class="ui info message" style="text-align: left">
<strong>{{ctx.Locale.Tr "repo.admin.enabled_flags"}}</strong>
{{range .Repository.ListFlags $.Context}}
<span class="ui label">{{.Name}}</span>
{{end}}
</div>
{{end}}

33
templates/repo/flags.tmpl Normal file
View File

@ -0,0 +1,33 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<div class="user-main-content twelve wide column">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.admin.manage_flags"}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<strong>{{ctx.Locale.Tr "repo.admin.enabled_flags"}}</strong>
<div class="ui segment gt-pl-4">
{{range $flag, $checked := .Flags}}
<div class="field">
<div class="ui checkbox{{if $checked}} checked{{end}}">
<input name="flags" type="checkbox" value="{{$flag}}" {{if $checked}}checked{{end}}>
<label>{{$flag}}</label>
</div>
</div>
{{end}}
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "repo.admin.update_flags"}}</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{template "base/footer" .}}

View File

@ -212,6 +212,12 @@
{{template "custom/extra_tabs" .}}
{{if and RepoFlagsEnabled .SignedUser.IsAdmin}}
<a class="{{if .IsRepoFlagsPage}}active {{end}}item" href="{{.RepoLink}}/flags">
{{svg "octicon-milestone"}} {{ctx.Locale.Tr "repo.admin.manage_flags"}}
</a>
{{end}}
{{if .Permission.IsAdmin}}
<a class="{{if .PageIsRepoSettings}}active {{end}} item" href="{{.RepoLink}}/settings">
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}

View File

@ -52,6 +52,14 @@
</div>
</div>
{{end}}
{{if RepoFlagsEnabled}}
{{template "custom/repo_flag_banners" .}}
{{if .SignedUser.IsAdmin}}
{{template "repo/admin_flags" .}}
{{end}}
{{end}}
{{if .Repository.IsArchived}}
<div class="ui warning message gt-text-center">
{{if .Repository.ArchivedUnix.IsZero}}

View File

@ -0,0 +1,242 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/routers"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestRepositoryFlagsUIDisabled(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Repository.EnableFlags, false)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
session := loginUser(t, admin.Name)
// With the repo flags feature disabled, the /flags route is 404
req := NewRequest(t, "GET", "/user2/repo1/flags")
session.MakeRequest(t, req, http.StatusNotFound)
// With the repo flags feature disabled, the "Modify flags" tab does not
// appear for instance admins
req = NewRequest(t, "GET", "/user2/repo1")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, "/user2/repo1")).Length()
assert.Equal(t, 0, flagsLinkCount)
}
func TestRepositoryFlagsUI(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Repository.EnableFlags, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
// *******************
// ** Preparations **
// *******************
flaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
unflaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
// **************
// ** Helpers **
// **************
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}).Name
flaggedOwner := "user2"
flaggedRepoURLStr := "/user2/repo1"
unflaggedOwner := "user5"
unflaggedRepoURLStr := "/user5/repo4"
otherUser := "user4"
ensureFlags := func(repo *repo_model.Repository, flags []string) func() {
repo.ReplaceAllFlags(db.DefaultContext, flags)
return func() {
repo.ReplaceAllFlags(db.DefaultContext, flags)
}
}
// Tests:
// - Presence of the link
// - Number of flags listed in the admin-only message box
// - Whether there's a link to /user/repo/flags
// - Whether /user/repo/flags is OK or Forbidden
assertFlagAccessAndCount := func(t *testing.T, user, repoURL string, hasAccess bool, expectedFlagCount int) {
t.Helper()
var expectedLinkCount int
var expectedStatus int
if hasAccess {
expectedLinkCount = 1
expectedStatus = http.StatusOK
} else {
expectedLinkCount = 0
if user != "" {
expectedStatus = http.StatusForbidden
} else {
expectedStatus = http.StatusSeeOther
}
}
var resp *httptest.ResponseRecorder
var session *TestSession
req := NewRequest(t, "GET", repoURL)
if user != "" {
session = loginUser(t, user)
resp = session.MakeRequest(t, req, http.StatusOK)
} else {
resp = MakeRequest(t, req, http.StatusOK)
}
doc := NewHTMLParser(t, resp.Body)
flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, repoURL)).Length()
assert.Equal(t, expectedLinkCount, flagsLinkCount)
flagCount := doc.Find(".ui.info.message .ui.label").Length()
assert.Equal(t, expectedFlagCount, flagCount)
req = NewRequest(t, "GET", fmt.Sprintf("%s/flags", repoURL))
if user != "" {
session.MakeRequest(t, req, expectedStatus)
} else {
MakeRequest(t, req, expectedStatus)
}
}
// Ensures that given a repo owner and a repo:
// - An instance admin has access to flags, and sees the list on the repo home
// - A repo admin does not have access to either, and does not see the list
// - A passer by has no access to either, and does not see the list
runTests := func(t *testing.T, ownerUser, repoURL string, expectedFlagCount int) {
t.Run("as instance admin", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertFlagAccessAndCount(t, adminUser, repoURL, true, expectedFlagCount)
})
t.Run("as owner", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertFlagAccessAndCount(t, ownerUser, repoURL, false, 0)
})
t.Run("as other user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertFlagAccessAndCount(t, otherUser, repoURL, false, 0)
})
t.Run("as non-logged in user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertFlagAccessAndCount(t, "", repoURL, false, 0)
})
}
// **************************
// ** The tests themselves **
// **************************
t.Run("unflagged repo", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer ensureFlags(unflaggedRepo, []string{})()
runTests(t, unflaggedOwner, unflaggedRepoURLStr, 0)
})
t.Run("flagged repo", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
runTests(t, flaggedOwner, flaggedRepoURLStr, 1)
})
t.Run("modifying flags", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, adminUser)
flaggedRepoManageURL := fmt.Sprintf("%s/flags", flaggedRepoURLStr)
unflaggedRepoManageURL := fmt.Sprintf("%s/flags", unflaggedRepoURLStr)
assertUIFlagStates := func(t *testing.T, url string, flagStates map[string]bool) {
t.Helper()
req := NewRequest(t, "GET", url)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
flagBoxes := doc.Find(`input[name="flags"]`)
assert.Equal(t, len(flagStates), flagBoxes.Length())
for name, state := range flagStates {
_, checked := doc.Find(fmt.Sprintf(`input[value="%s"]`, name)).Attr("checked")
assert.Equal(t, state, checked)
}
}
t.Run("flag presence on the UI", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{"test-flag": true})
})
t.Run("setting.Repository.SettableFlags is respected", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.Repository.SettableFlags, []string{"featured", "no-license"})()
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{
"test-flag": true,
"featured": false,
"no-license": false,
})
})
t.Run("removing flags", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
flagged := flaggedRepo.IsFlagged(db.DefaultContext)
assert.True(t, flagged)
req := NewRequestWithValues(t, "POST", flaggedRepoManageURL, map[string]string{
"_csrf": GetCSRF(t, session, flaggedRepoManageURL),
})
session.MakeRequest(t, req, http.StatusSeeOther)
flagged = flaggedRepo.IsFlagged(db.DefaultContext)
assert.False(t, flagged)
assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{})
})
t.Run("adding flags", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer ensureFlags(unflaggedRepo, []string{})()
flagged := unflaggedRepo.IsFlagged(db.DefaultContext)
assert.False(t, flagged)
req := NewRequestWithValues(t, "POST", unflaggedRepoManageURL, map[string]string{
"_csrf": GetCSRF(t, session, unflaggedRepoManageURL),
"flags": "test-flag",
})
session.MakeRequest(t, req, http.StatusSeeOther)
assertUIFlagStates(t, unflaggedRepoManageURL, map[string]bool{"test-flag": true})
})
})
}