diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 8ac2fb63f2..f0e22c046f 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -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. diff --git a/models/forgejo_migrations/v1_22/v5.go b/models/forgejo_migrations/v1_22/v5.go new file mode 100644 index 0000000000..55f9fe1338 --- /dev/null +++ b/models/forgejo_migrations/v1_22/v5.go @@ -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)) +} diff --git a/models/repo/repo_flags.go b/models/repo/repo_flags.go new file mode 100644 index 0000000000..de76ed2b37 --- /dev/null +++ b/models/repo/repo_flags.go @@ -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() +} diff --git a/models/repo/repo_flags_test.go b/models/repo/repo_flags_test.go new file mode 100644 index 0000000000..0e4f5c1ba9 --- /dev/null +++ b/models/repo/repo_flags_test.go @@ -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) +} diff --git a/modules/setting/repository.go b/modules/setting/repository.go index b6b0b50504..fd17fbf55d 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -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() } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 96cdd9ca46..bcb94bff25 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -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 }, diff --git a/routers/web/repo/flags/manage.go b/routers/web/repo/flags/manage.go new file mode 100644 index 0000000000..840f6c3773 --- /dev/null +++ b/routers/web/repo/flags/manage.go @@ -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") +} diff --git a/routers/web/web.go b/routers/web/web.go index c15d1800f3..f9611b5f4b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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() { diff --git a/templates/custom/repo_flag_banners.tmpl b/templates/custom/repo_flag_banners.tmpl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/templates/repo/admin_flags.tmpl b/templates/repo/admin_flags.tmpl new file mode 100644 index 0000000000..2a65c9c687 --- /dev/null +++ b/templates/repo/admin_flags.tmpl @@ -0,0 +1,8 @@ +{{if .Repository.IsFlagged $.Context}} +
+{{end}} diff --git a/templates/repo/flags.tmpl b/templates/repo/flags.tmpl new file mode 100644 index 0000000000..3928cf0e17 --- /dev/null +++ b/templates/repo/flags.tmpl @@ -0,0 +1,33 @@ +{{template "base/head" .}} +