summaryrefslogtreecommitdiffstats
path: root/routers/web/repo/setting
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /routers/web/repo/setting
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--routers/web/repo/setting/avatar.go76
-rw-r--r--routers/web/repo/setting/collaboration.go217
-rw-r--r--routers/web/repo/setting/default_branch.go54
-rw-r--r--routers/web/repo/setting/deploy_key.go109
-rw-r--r--routers/web/repo/setting/git_hooks.go65
-rw-r--r--routers/web/repo/setting/lfs.go562
-rw-r--r--routers/web/repo/setting/main_test.go14
-rw-r--r--routers/web/repo/setting/protected_branch.go347
-rw-r--r--routers/web/repo/setting/protected_tag.go188
-rw-r--r--routers/web/repo/setting/runners.go187
-rw-r--r--routers/web/repo/setting/secrets.go127
-rw-r--r--routers/web/repo/setting/setting.go1113
-rw-r--r--routers/web/repo/setting/settings_test.go412
-rw-r--r--routers/web/repo/setting/variables.go140
-rw-r--r--routers/web/repo/setting/webhook.go485
15 files changed, 4096 insertions, 0 deletions
diff --git a/routers/web/repo/setting/avatar.go b/routers/web/repo/setting/avatar.go
new file mode 100644
index 0000000..504f57c
--- /dev/null
+++ b/routers/web/repo/setting/avatar.go
@@ -0,0 +1,76 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "io"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/typesniffer"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// UpdateAvatarSetting update repo's avatar
+func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error {
+ ctxRepo := ctx.Repo.Repository
+
+ if form.Avatar == nil {
+ // No avatar is uploaded and we not removing it here.
+ // No random avatar generated here.
+ // Just exit, no action.
+ if ctxRepo.CustomAvatarRelativePath() == "" {
+ log.Trace("No avatar was uploaded for repo: %d. Default icon will appear instead.", ctxRepo.ID)
+ }
+ return nil
+ }
+
+ r, err := form.Avatar.Open()
+ if err != nil {
+ return fmt.Errorf("Avatar.Open: %w", err)
+ }
+ defer r.Close()
+
+ if form.Avatar.Size > setting.Avatar.MaxFileSize {
+ return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
+ }
+
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return fmt.Errorf("io.ReadAll: %w", err)
+ }
+ st := typesniffer.DetectContentType(data)
+ if !(st.IsImage() && !st.IsSvgImage()) {
+ return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image"))
+ }
+ if err = repo_service.UploadAvatar(ctx, ctxRepo, data); err != nil {
+ return fmt.Errorf("UploadAvatar: %w", err)
+ }
+ return nil
+}
+
+// SettingsAvatar save new POSTed repository avatar
+func SettingsAvatar(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AvatarForm)
+ form.Source = forms.AvatarLocal
+ if err := UpdateAvatarSetting(ctx, *form); err != nil {
+ ctx.Flash.Error(err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_avatar_success"))
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+}
+
+// SettingsDeleteAvatar delete repository avatar
+func SettingsDeleteAvatar(ctx *context.Context) {
+ if err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository); err != nil {
+ ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err))
+ }
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings")
+}
diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go
new file mode 100644
index 0000000..75b5515
--- /dev/null
+++ b/routers/web/repo/setting/collaboration.go
@@ -0,0 +1,217 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/mailer"
+ org_service "code.gitea.io/gitea/services/org"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// Collaboration render a repository's collaboration page
+func Collaboration(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.collaboration")
+ ctx.Data["PageIsSettingsCollaboration"] = true
+
+ users, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, db.ListOptions{})
+ if err != nil {
+ ctx.ServerError("GetCollaborators", err)
+ return
+ }
+ ctx.Data["Collaborators"] = users
+
+ teams, err := organization.GetRepoTeams(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError("GetRepoTeams", err)
+ return
+ }
+ ctx.Data["Teams"] = teams
+ ctx.Data["Repo"] = ctx.Repo.Repository
+ ctx.Data["OrgID"] = ctx.Repo.Repository.OwnerID
+ ctx.Data["OrgName"] = ctx.Repo.Repository.OwnerName
+ ctx.Data["Org"] = ctx.Repo.Repository.Owner
+ ctx.Data["Units"] = unit_model.Units
+
+ ctx.HTML(http.StatusOK, tplCollaboration)
+}
+
+// CollaborationPost response for actions for a collaboration of a repository
+func CollaborationPost(ctx *context.Context) {
+ name := strings.ToLower(ctx.FormString("collaborator"))
+ if len(name) == 0 || ctx.Repo.Owner.LowerName == name {
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+ return
+ }
+
+ u, err := user_model.GetUserByName(ctx, name)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+ } else {
+ ctx.ServerError("GetUserByName", err)
+ }
+ return
+ }
+
+ if !u.IsActive {
+ ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_inactive_user"))
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+ return
+ }
+
+ // Organization is not allowed to be added as a collaborator.
+ if u.IsOrganization() {
+ ctx.Flash.Error(ctx.Tr("repo.settings.org_not_allowed_to_be_collaborator"))
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+ return
+ }
+
+ if got, err := repo_model.IsCollaborator(ctx, ctx.Repo.Repository.ID, u.ID); err == nil && got {
+ ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_duplicate"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ return
+ }
+
+ // find the owner team of the organization the repo belongs too and
+ // check if the user we're trying to add is an owner.
+ if ctx.Repo.Repository.Owner.IsOrganization() {
+ if isOwner, err := organization.IsOrganizationOwner(ctx, ctx.Repo.Repository.Owner.ID, u.ID); err != nil {
+ ctx.ServerError("IsOrganizationOwner", err)
+ return
+ } else if isOwner {
+ ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_owner"))
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+ return
+ }
+ }
+
+ if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil {
+ if !errors.Is(err, user_model.ErrBlockedByUser) {
+ ctx.ServerError("AddCollaborator", err)
+ return
+ }
+
+ // To give an good error message, be precise on who has blocked who.
+ if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs {
+ ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them"))
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ return
+ }
+
+ if setting.Service.EnableNotifyMail {
+ mailer.SendCollaboratorMail(u, ctx.Doer, ctx.Repo.Repository)
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.add_collaborator_success"))
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+}
+
+// ChangeCollaborationAccessMode response for changing access of a collaboration
+func ChangeCollaborationAccessMode(ctx *context.Context) {
+ if err := repo_model.ChangeCollaborationAccessMode(
+ ctx,
+ ctx.Repo.Repository,
+ ctx.FormInt64("uid"),
+ perm.AccessMode(ctx.FormInt("mode"))); err != nil {
+ log.Error("ChangeCollaborationAccessMode: %v", err)
+ }
+}
+
+// DeleteCollaboration delete a collaboration for a repository
+func DeleteCollaboration(ctx *context.Context) {
+ if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeleteCollaboration: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success"))
+ }
+
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration")
+}
+
+// AddTeamPost response for adding a team to a repository
+func AddTeamPost(ctx *context.Context) {
+ if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() {
+ ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ return
+ }
+
+ name := strings.ToLower(ctx.FormString("team"))
+ if len(name) == 0 {
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ return
+ }
+
+ team, err := organization.OrgFromUser(ctx.Repo.Owner).GetTeam(ctx, name)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ ctx.Flash.Error(ctx.Tr("form.team_not_exist"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ } else {
+ ctx.ServerError("GetTeam", err)
+ }
+ return
+ }
+
+ if team.OrgID != ctx.Repo.Repository.OwnerID {
+ ctx.Flash.Error(ctx.Tr("repo.settings.team_not_in_organization"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ return
+ }
+
+ if organization.HasTeamRepo(ctx, ctx.Repo.Repository.OwnerID, team.ID, ctx.Repo.Repository.ID) {
+ ctx.Flash.Error(ctx.Tr("repo.settings.add_team_duplicate"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ return
+ }
+
+ if err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil {
+ ctx.ServerError("TeamAddRepository", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.add_team_success"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+}
+
+// DeleteTeam response for deleting a team from a repository
+func DeleteTeam(ctx *context.Context) {
+ if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() {
+ ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
+ return
+ }
+
+ team, err := organization.GetTeamByID(ctx, ctx.FormInt64("id"))
+ if err != nil {
+ ctx.ServerError("GetTeamByID", err)
+ return
+ }
+
+ if err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID); err != nil {
+ ctx.ServerError("team.RemoveRepositorys", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.remove_team_success"))
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration")
+}
diff --git a/routers/web/repo/setting/default_branch.go b/routers/web/repo/setting/default_branch.go
new file mode 100644
index 0000000..881d148
--- /dev/null
+++ b/routers/web/repo/setting/default_branch.go
@@ -0,0 +1,54 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers/web/repo"
+ "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// SetDefaultBranchPost set default branch
+func SetDefaultBranchPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.branches.update_default_branch")
+ ctx.Data["PageIsSettingsBranches"] = true
+
+ repo.PrepareBranchList(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ repo := ctx.Repo.Repository
+
+ switch ctx.FormString("action") {
+ case "default_branch":
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplBranches)
+ return
+ }
+
+ branch := ctx.FormString("branch")
+ if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, branch); err != nil {
+ switch {
+ case git_model.IsErrBranchNotExist(err):
+ ctx.Status(http.StatusNotFound)
+ default:
+ ctx.ServerError("SetDefaultBranch", err)
+ }
+ return
+ }
+
+ log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+ default:
+ ctx.NotFound("", nil)
+ }
+}
diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go
new file mode 100644
index 0000000..abc3eb4
--- /dev/null
+++ b/routers/web/repo/setting/deploy_key.go
@@ -0,0 +1,109 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ asymkey_service "code.gitea.io/gitea/services/asymkey"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+// DeployKeys render the deploy keys list of a repository page
+func DeployKeys(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets")
+ ctx.Data["PageIsSettingsKeys"] = true
+ ctx.Data["DisableSSH"] = setting.SSH.Disabled
+
+ keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
+ if err != nil {
+ ctx.ServerError("ListDeployKeys", err)
+ return
+ }
+ ctx.Data["Deploykeys"] = keys
+
+ ctx.HTML(http.StatusOK, tplDeployKeys)
+}
+
+// DeployKeysPost response for adding a deploy key of a repository
+func DeployKeysPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AddKeyForm)
+ ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
+ ctx.Data["PageIsSettingsKeys"] = true
+ ctx.Data["DisableSSH"] = setting.SSH.Disabled
+
+ keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
+ if err != nil {
+ ctx.ServerError("ListDeployKeys", err)
+ return
+ }
+ ctx.Data["Deploykeys"] = keys
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplDeployKeys)
+ return
+ }
+
+ content, err := asymkey_model.CheckPublicKeyString(form.Content)
+ if err != nil {
+ if db.IsErrSSHDisabled(err) {
+ ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
+ } else if asymkey_model.IsErrKeyUnableVerify(err) {
+ ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key"))
+ } else if err == asymkey_model.ErrKeyIsPrivate {
+ ctx.Data["HasError"] = true
+ ctx.Data["Err_Content"] = true
+ ctx.Flash.Error(ctx.Tr("form.must_use_public_key"))
+ } else {
+ ctx.Data["HasError"] = true
+ ctx.Data["Err_Content"] = true
+ ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error()))
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys")
+ return
+ }
+
+ key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable)
+ if err != nil {
+ ctx.Data["HasError"] = true
+ switch {
+ case asymkey_model.IsErrDeployKeyAlreadyExist(err):
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("repo.settings.key_been_used"), tplDeployKeys, &form)
+ case asymkey_model.IsErrKeyAlreadyExist(err):
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplDeployKeys, &form)
+ case asymkey_model.IsErrKeyNameAlreadyUsed(err):
+ ctx.Data["Err_Title"] = true
+ ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), tplDeployKeys, &form)
+ case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
+ ctx.Data["Err_Title"] = true
+ ctx.RenderWithErr(ctx.Tr("repo.settings.key_name_used"), tplDeployKeys, &form)
+ default:
+ ctx.ServerError("AddDeployKey", err)
+ }
+ return
+ }
+
+ log.Trace("Deploy key added: %d", ctx.Repo.Repository.ID)
+ ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys")
+}
+
+// DeleteDeployKey response for deleting a deploy key
+func DeleteDeployKey(ctx *context.Context) {
+ if err := asymkey_service.DeleteDeployKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeleteDeployKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
+ }
+
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
+}
diff --git a/routers/web/repo/setting/git_hooks.go b/routers/web/repo/setting/git_hooks.go
new file mode 100644
index 0000000..217a01c
--- /dev/null
+++ b/routers/web/repo/setting/git_hooks.go
@@ -0,0 +1,65 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/services/context"
+)
+
+// GitHooks hooks of a repository
+func GitHooks(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.githooks")
+ ctx.Data["PageIsSettingsGitHooks"] = true
+
+ hooks, err := ctx.Repo.GitRepo.Hooks()
+ if err != nil {
+ ctx.ServerError("Hooks", err)
+ return
+ }
+ ctx.Data["Hooks"] = hooks
+
+ ctx.HTML(http.StatusOK, tplGithooks)
+}
+
+// GitHooksEdit render for editing a hook of repository page
+func GitHooksEdit(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.githooks")
+ ctx.Data["PageIsSettingsGitHooks"] = true
+
+ name := ctx.Params(":name")
+ hook, err := ctx.Repo.GitRepo.GetHook(name)
+ if err != nil {
+ if err == git.ErrNotValidHook {
+ ctx.NotFound("GetHook", err)
+ } else {
+ ctx.ServerError("GetHook", err)
+ }
+ return
+ }
+ ctx.Data["Hook"] = hook
+ ctx.HTML(http.StatusOK, tplGithookEdit)
+}
+
+// GitHooksEditPost response for editing a git hook of a repository
+func GitHooksEditPost(ctx *context.Context) {
+ name := ctx.Params(":name")
+ hook, err := ctx.Repo.GitRepo.GetHook(name)
+ if err != nil {
+ if err == git.ErrNotValidHook {
+ ctx.NotFound("GetHook", err)
+ } else {
+ ctx.ServerError("GetHook", err)
+ }
+ return
+ }
+ hook.Content = ctx.FormString("content")
+ if err = hook.Update(); err != nil {
+ ctx.ServerError("hook.Update", err)
+ return
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/hooks/git")
+}
diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go
new file mode 100644
index 0000000..7e36343
--- /dev/null
+++ b/routers/web/repo/setting/lfs.go
@@ -0,0 +1,562 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "bytes"
+ "fmt"
+ gotemplate "html/template"
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/charset"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/git/pipeline"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/log"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/typesniffer"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsLFS base.TplName = "repo/settings/lfs"
+ tplSettingsLFSLocks base.TplName = "repo/settings/lfs_locks"
+ tplSettingsLFSFile base.TplName = "repo/settings/lfs_file"
+ tplSettingsLFSFileFind base.TplName = "repo/settings/lfs_file_find"
+ tplSettingsLFSPointers base.TplName = "repo/settings/lfs_pointers"
+)
+
+// LFSFiles shows a repository's LFS files
+func LFSFiles(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSFiles", nil)
+ return
+ }
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ total, err := git_model.CountLFSMetaObjects(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("LFSFiles", err)
+ return
+ }
+ ctx.Data["Total"] = total
+
+ pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5)
+ ctx.Data["Title"] = ctx.Tr("repo.settings.lfs")
+ ctx.Data["PageIsSettingsLFS"] = true
+ lfsMetaObjects, err := git_model.GetLFSMetaObjects(ctx, ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum)
+ if err != nil {
+ ctx.ServerError("LFSFiles", err)
+ return
+ }
+ ctx.Data["LFSFiles"] = lfsMetaObjects
+ ctx.Data["Page"] = pager
+ ctx.HTML(http.StatusOK, tplSettingsLFS)
+}
+
+// LFSLocks shows a repository's LFS locks
+func LFSLocks(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSLocks", nil)
+ return
+ }
+ ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ total, err := git_model.CountLFSLockByRepoID(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("LFSLocks", err)
+ return
+ }
+ ctx.Data["Total"] = total
+
+ pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5)
+ ctx.Data["Title"] = ctx.Tr("repo.settings.lfs_locks")
+ ctx.Data["PageIsSettingsLFS"] = true
+ lfsLocks, err := git_model.GetLFSLockByRepoID(ctx, ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum)
+ if err != nil {
+ ctx.ServerError("LFSLocks", err)
+ return
+ }
+ if err := lfsLocks.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LFSLocks", err)
+ return
+ }
+
+ ctx.Data["LFSLocks"] = lfsLocks
+
+ if len(lfsLocks) == 0 {
+ ctx.Data["Page"] = pager
+ ctx.HTML(http.StatusOK, tplSettingsLFSLocks)
+ return
+ }
+
+ // Clone base repo.
+ tmpBasePath, err := repo_module.CreateTemporaryPath("locks")
+ if err != nil {
+ log.Error("Failed to create temporary path: %v", err)
+ ctx.ServerError("LFSLocks", err)
+ return
+ }
+ defer func() {
+ if err := repo_module.RemoveTemporaryPath(tmpBasePath); err != nil {
+ log.Error("LFSLocks: RemoveTemporaryPath: %v", err)
+ }
+ }()
+
+ if err := git.Clone(ctx, ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{
+ Bare: true,
+ Shared: true,
+ }); err != nil {
+ log.Error("Failed to clone repository: %s (%v)", ctx.Repo.Repository.FullName(), err)
+ ctx.ServerError("LFSLocks", fmt.Errorf("failed to clone repository: %s (%w)", ctx.Repo.Repository.FullName(), err))
+ return
+ }
+
+ gitRepo, err := git.OpenRepository(ctx, tmpBasePath)
+ if err != nil {
+ log.Error("Unable to open temporary repository: %s (%v)", tmpBasePath, err)
+ ctx.ServerError("LFSLocks", fmt.Errorf("failed to open new temporary repository in: %s %w", tmpBasePath, err))
+ return
+ }
+ defer gitRepo.Close()
+
+ filenames := make([]string, len(lfsLocks))
+
+ for i, lock := range lfsLocks {
+ filenames[i] = lock.Path
+ }
+
+ if err := gitRepo.ReadTreeToIndex(ctx.Repo.Repository.DefaultBranch); err != nil {
+ log.Error("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err)
+ ctx.ServerError("LFSLocks", fmt.Errorf("unable to read the default branch to the index: %s (%w)", ctx.Repo.Repository.DefaultBranch, err))
+ return
+ }
+
+ ctx.Data["Lockables"], err = lockablesGitAttributes(gitRepo, lfsLocks)
+ if err != nil {
+ log.Error("Unable to get lockablesGitAttributes in %s (%v)", tmpBasePath, err)
+ ctx.ServerError("LFSLocks", err)
+ return
+ }
+
+ filelist, err := gitRepo.LsFiles(filenames...)
+ if err != nil {
+ log.Error("Unable to lsfiles in %s (%v)", tmpBasePath, err)
+ ctx.ServerError("LFSLocks", err)
+ return
+ }
+
+ fileset := make(container.Set[string], len(filelist))
+ fileset.AddMultiple(filelist...)
+
+ linkable := make([]bool, len(lfsLocks))
+ for i, lock := range lfsLocks {
+ linkable[i] = fileset.Contains(lock.Path)
+ }
+ ctx.Data["Linkable"] = linkable
+
+ ctx.Data["Page"] = pager
+ ctx.HTML(http.StatusOK, tplSettingsLFSLocks)
+}
+
+func lockablesGitAttributes(gitRepo *git.Repository, lfsLocks []*git_model.LFSLock) ([]bool, error) {
+ checker, err := gitRepo.GitAttributeChecker("", "lockable")
+ if err != nil {
+ return nil, fmt.Errorf("could not GitAttributeChecker: %w", err)
+ }
+ defer checker.Close()
+
+ lockables := make([]bool, len(lfsLocks))
+ for i, lock := range lfsLocks {
+ attrs, err := checker.CheckPath(lock.Path)
+ if err != nil {
+ return nil, fmt.Errorf("could not CheckPath(%s): %w", lock.Path, err)
+ }
+ lockables[i] = attrs["lockable"].Bool().Value()
+ }
+ return lockables, nil
+}
+
+// LFSLockFile locks a file
+func LFSLockFile(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSLocks", nil)
+ return
+ }
+ originalPath := ctx.FormString("path")
+ lockPath := originalPath
+ if len(lockPath) == 0 {
+ ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
+ return
+ }
+ if lockPath[len(lockPath)-1] == '/' {
+ ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_lock_directory", originalPath))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
+ return
+ }
+ lockPath = util.PathJoinRel(lockPath)
+ if len(lockPath) == 0 {
+ ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
+ return
+ }
+
+ _, err := git_model.CreateLFSLock(ctx, ctx.Repo.Repository, &git_model.LFSLock{
+ Path: lockPath,
+ OwnerID: ctx.Doer.ID,
+ })
+ if err != nil {
+ if git_model.IsErrLFSLockAlreadyExist(err) {
+ ctx.Flash.Error(ctx.Tr("repo.settings.lfs_lock_already_exists", originalPath))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
+ return
+ }
+ ctx.ServerError("LFSLockFile", err)
+ return
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
+}
+
+// LFSUnlock forcibly unlocks an LFS lock
+func LFSUnlock(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSUnlock", nil)
+ return
+ }
+ _, err := git_model.DeleteLFSLockByID(ctx, ctx.ParamsInt64("lid"), ctx.Repo.Repository, ctx.Doer, true)
+ if err != nil {
+ ctx.ServerError("LFSUnlock", err)
+ return
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
+}
+
+// LFSFileGet serves a single LFS file
+func LFSFileGet(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSFileGet", nil)
+ return
+ }
+ ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
+ oid := ctx.Params("oid")
+
+ p := lfs.Pointer{Oid: oid}
+ if !p.IsValid() {
+ ctx.NotFound("LFSFileGet", nil)
+ return
+ }
+
+ ctx.Data["Title"] = oid
+ ctx.Data["PageIsSettingsLFS"] = true
+ meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, oid)
+ if err != nil {
+ if err == git_model.ErrLFSObjectNotExist {
+ ctx.NotFound("LFSFileGet", nil)
+ return
+ }
+ ctx.ServerError("LFSFileGet", err)
+ return
+ }
+ ctx.Data["LFSFile"] = meta
+ dataRc, err := lfs.ReadMetaObject(meta.Pointer)
+ if err != nil {
+ ctx.ServerError("LFSFileGet", err)
+ return
+ }
+ defer dataRc.Close()
+ buf := make([]byte, 1024)
+ n, err := util.ReadAtMost(dataRc, buf)
+ if err != nil {
+ ctx.ServerError("Data", err)
+ return
+ }
+ buf = buf[:n]
+
+ st := typesniffer.DetectContentType(buf)
+ ctx.Data["IsTextFile"] = st.IsText()
+ isRepresentableAsText := st.IsRepresentableAsText()
+
+ fileSize := meta.Size
+ ctx.Data["FileSize"] = meta.Size
+ ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct")
+ switch {
+ case isRepresentableAsText:
+ if st.IsSvgImage() {
+ ctx.Data["IsImageFile"] = true
+ }
+
+ if fileSize >= setting.UI.MaxDisplayFileSize {
+ ctx.Data["IsFileTooLarge"] = true
+ break
+ }
+
+ rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{})
+
+ // Building code view blocks with line number on server side.
+ escapedContent := &bytes.Buffer{}
+ ctx.Data["EscapeStatus"], _ = charset.EscapeControlReader(rd, escapedContent, ctx.Locale, charset.FileviewContext)
+
+ var output bytes.Buffer
+ lines := strings.Split(escapedContent.String(), "\n")
+ // Remove blank line at the end of file
+ if len(lines) > 0 && lines[len(lines)-1] == "" {
+ lines = lines[:len(lines)-1]
+ }
+ for index, line := range lines {
+ line = gotemplate.HTMLEscapeString(line)
+ if index != len(lines)-1 {
+ line += "\n"
+ }
+ output.WriteString(fmt.Sprintf(`<li class="L%d" rel="L%d">%s</li>`, index+1, index+1, line))
+ }
+ ctx.Data["FileContent"] = gotemplate.HTML(output.String())
+
+ output.Reset()
+ for i := 0; i < len(lines); i++ {
+ output.WriteString(fmt.Sprintf(`<span id="L%d">%d</span>`, i+1, i+1))
+ }
+ ctx.Data["LineNums"] = gotemplate.HTML(output.String())
+
+ case st.IsPDF():
+ ctx.Data["IsPDFFile"] = true
+ case st.IsVideo():
+ ctx.Data["IsVideoFile"] = true
+ case st.IsAudio():
+ ctx.Data["IsAudioFile"] = true
+ case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
+ ctx.Data["IsImageFile"] = true
+ }
+ ctx.HTML(http.StatusOK, tplSettingsLFSFile)
+}
+
+// LFSDelete disassociates the provided oid from the repository and if the lfs file is no longer associated with any repositories - deletes it
+func LFSDelete(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSDelete", nil)
+ return
+ }
+ oid := ctx.Params("oid")
+ p := lfs.Pointer{Oid: oid}
+ if !p.IsValid() {
+ ctx.NotFound("LFSDelete", nil)
+ return
+ }
+
+ count, err := git_model.RemoveLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, oid)
+ if err != nil {
+ ctx.ServerError("LFSDelete", err)
+ return
+ }
+ // FIXME: Warning: the LFS store is not locked - and can't be locked - there could be a race condition here
+ // Please note a similar condition happens in models/repo.go DeleteRepository
+ if count == 0 {
+ oidPath := path.Join(oid[0:2], oid[2:4], oid[4:])
+ err = storage.LFS.Delete(oidPath)
+ if err != nil {
+ ctx.ServerError("LFSDelete", err)
+ return
+ }
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs")
+}
+
+// LFSFileFind guesses a sha for the provided oid (or uses the provided sha) and then finds the commits that contain this sha
+func LFSFileFind(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSFind", nil)
+ return
+ }
+ oid := ctx.FormString("oid")
+ size := ctx.FormInt64("size")
+ if len(oid) == 0 || size == 0 {
+ ctx.NotFound("LFSFind", nil)
+ return
+ }
+ sha := ctx.FormString("sha")
+ ctx.Data["Title"] = oid
+ ctx.Data["PageIsSettingsLFS"] = true
+ objectFormat := ctx.Repo.GetObjectFormat()
+ var objectID git.ObjectID
+ if len(sha) == 0 {
+ pointer := lfs.Pointer{Oid: oid, Size: size}
+ objectID = git.ComputeBlobHash(objectFormat, []byte(pointer.StringContent()))
+ sha = objectID.String()
+ } else {
+ objectID = git.MustIDFromString(sha)
+ }
+ ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
+ ctx.Data["Oid"] = oid
+ ctx.Data["Size"] = size
+ ctx.Data["SHA"] = sha
+
+ results, err := pipeline.FindLFSFile(ctx.Repo.GitRepo, objectID)
+ if err != nil && err != io.EOF {
+ log.Error("Failure in FindLFSFile: %v", err)
+ ctx.ServerError("LFSFind: FindLFSFile.", err)
+ return
+ }
+
+ ctx.Data["Results"] = results
+ ctx.HTML(http.StatusOK, tplSettingsLFSFileFind)
+}
+
+// LFSPointerFiles will search the repository for pointer files and report which are missing LFS files in the content store
+func LFSPointerFiles(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSFileGet", nil)
+ return
+ }
+ ctx.Data["PageIsSettingsLFS"] = true
+ ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
+
+ var err error
+ err = func() error {
+ pointerChan := make(chan lfs.PointerBlob)
+ errChan := make(chan error, 1)
+ go lfs.SearchPointerBlobs(ctx, ctx.Repo.GitRepo, pointerChan, errChan)
+
+ numPointers := 0
+ var numAssociated, numNoExist, numAssociatable int
+
+ type pointerResult struct {
+ SHA string
+ Oid string
+ Size int64
+ InRepo bool
+ Exists bool
+ Accessible bool
+ Associatable bool
+ }
+
+ results := []pointerResult{}
+
+ contentStore := lfs.NewContentStore()
+ repo := ctx.Repo.Repository
+
+ for pointerBlob := range pointerChan {
+ numPointers++
+
+ result := pointerResult{
+ SHA: pointerBlob.Hash,
+ Oid: pointerBlob.Oid,
+ Size: pointerBlob.Size,
+ }
+
+ if _, err := git_model.GetLFSMetaObjectByOid(ctx, repo.ID, pointerBlob.Oid); err != nil {
+ if err != git_model.ErrLFSObjectNotExist {
+ return err
+ }
+ } else {
+ result.InRepo = true
+ }
+
+ result.Exists, err = contentStore.Exists(pointerBlob.Pointer)
+ if err != nil {
+ return err
+ }
+
+ if result.Exists {
+ if !result.InRepo {
+ // Can we fix?
+ // OK well that's "simple"
+ // - we need to check whether current user has access to a repo that has access to the file
+ result.Associatable, err = git_model.LFSObjectAccessible(ctx, ctx.Doer, pointerBlob.Oid)
+ if err != nil {
+ return err
+ }
+ if !result.Associatable {
+ associated, err := git_model.ExistsLFSObject(ctx, pointerBlob.Oid)
+ if err != nil {
+ return err
+ }
+ result.Associatable = !associated
+ }
+ }
+ }
+
+ result.Accessible = result.InRepo || result.Associatable
+
+ if result.InRepo {
+ numAssociated++
+ }
+ if !result.Exists {
+ numNoExist++
+ }
+ if result.Associatable {
+ numAssociatable++
+ }
+
+ results = append(results, result)
+ }
+
+ err, has := <-errChan
+ if has {
+ return err
+ }
+
+ ctx.Data["Pointers"] = results
+ ctx.Data["NumPointers"] = numPointers
+ ctx.Data["NumAssociated"] = numAssociated
+ ctx.Data["NumAssociatable"] = numAssociatable
+ ctx.Data["NumNoExist"] = numNoExist
+ ctx.Data["NumNotAssociated"] = numPointers - numAssociated
+
+ return nil
+ }()
+ if err != nil {
+ ctx.ServerError("LFSPointerFiles", err)
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplSettingsLFSPointers)
+}
+
+// LFSAutoAssociate auto associates accessible lfs files
+func LFSAutoAssociate(ctx *context.Context) {
+ if !setting.LFS.StartServer {
+ ctx.NotFound("LFSAutoAssociate", nil)
+ return
+ }
+ oids := ctx.FormStrings("oid")
+ metas := make([]*git_model.LFSMetaObject, len(oids))
+ for i, oid := range oids {
+ idx := strings.IndexRune(oid, ' ')
+ if idx < 0 || idx+1 > len(oid) {
+ ctx.ServerError("LFSAutoAssociate", fmt.Errorf("illegal oid input: %s", oid))
+ return
+ }
+ var err error
+ metas[i] = &git_model.LFSMetaObject{}
+ metas[i].Size, err = strconv.ParseInt(oid[idx+1:], 10, 64)
+ if err != nil {
+ ctx.ServerError("LFSAutoAssociate", fmt.Errorf("illegal oid input: %s %w", oid, err))
+ return
+ }
+ metas[i].Oid = oid[:idx]
+ // metas[i].RepositoryID = ctx.Repo.Repository.ID
+ }
+ if err := git_model.LFSAutoAssociate(ctx, metas, ctx.Doer, ctx.Repo.Repository.ID); err != nil {
+ ctx.ServerError("LFSAutoAssociate", err)
+ return
+ }
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs")
+}
diff --git a/routers/web/repo/setting/main_test.go b/routers/web/repo/setting/main_test.go
new file mode 100644
index 0000000..c414b85
--- /dev/null
+++ b/routers/web/repo/setting/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go
new file mode 100644
index 0000000..b2f5798
--- /dev/null
+++ b/routers/web/repo/setting/protected_branch.go
@@ -0,0 +1,347 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/web/repo"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ pull_service "code.gitea.io/gitea/services/pull"
+ "code.gitea.io/gitea/services/repository"
+
+ "github.com/gobwas/glob"
+)
+
+const (
+ tplProtectedBranch base.TplName = "repo/settings/protected_branch"
+)
+
+// ProtectedBranchRules render the page to protect the repository
+func ProtectedBranchRules(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.branches")
+ ctx.Data["PageIsSettingsBranches"] = true
+
+ rules, err := git_model.FindRepoProtectedBranchRules(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("GetProtectedBranches", err)
+ return
+ }
+ ctx.Data["ProtectedBranches"] = rules
+
+ repo.PrepareBranchList(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplBranches)
+}
+
+// SettingsProtectedBranch renders the protected branch setting page
+func SettingsProtectedBranch(c *context.Context) {
+ ruleName := c.FormString("rule_name")
+ var rule *git_model.ProtectedBranch
+ if ruleName != "" {
+ var err error
+ rule, err = git_model.GetProtectedBranchRuleByName(c, c.Repo.Repository.ID, ruleName)
+ if err != nil {
+ c.ServerError("GetProtectBranchOfRepoByName", err)
+ return
+ }
+ }
+
+ if rule == nil {
+ // No options found, create defaults.
+ rule = &git_model.ProtectedBranch{}
+ }
+
+ c.Data["PageIsSettingsBranches"] = true
+ c.Data["Title"] = c.Locale.TrString("repo.settings.protected_branch") + " - " + rule.RuleName
+
+ users, err := access_model.GetRepoReaders(c, c.Repo.Repository)
+ if err != nil {
+ c.ServerError("Repo.Repository.GetReaders", err)
+ return
+ }
+ c.Data["Users"] = users
+ c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",")
+ c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",")
+ c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",")
+ c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n")
+ contexts, _ := git_model.FindRepoRecentCommitStatusContexts(c, c.Repo.Repository.ID, 7*24*time.Hour) // Find last week status check contexts
+ c.Data["recent_status_checks"] = contexts
+
+ if c.Repo.Owner.IsOrganization() {
+ teams, err := organization.OrgFromUser(c.Repo.Owner).TeamsWithAccessToRepo(c, c.Repo.Repository.ID, perm.AccessModeRead)
+ if err != nil {
+ c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err)
+ return
+ }
+ c.Data["Teams"] = teams
+ c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",")
+ c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",")
+ c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",")
+ }
+
+ c.Data["Rule"] = rule
+ c.HTML(http.StatusOK, tplProtectedBranch)
+}
+
+// SettingsProtectedBranchPost updates the protected branch settings
+func SettingsProtectedBranchPost(ctx *context.Context) {
+ f := web.GetForm(ctx).(*forms.ProtectBranchForm)
+ var protectBranch *git_model.ProtectedBranch
+ if f.RuleName == "" {
+ ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_rule_name"))
+ ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit", ctx.Repo.RepoLink))
+ return
+ }
+
+ var err error
+ if f.RuleID > 0 {
+ // If the RuleID isn't 0, it must be an edit operation. So we get rule by id.
+ protectBranch, err = git_model.GetProtectedBranchRuleByID(ctx, ctx.Repo.Repository.ID, f.RuleID)
+ if err != nil {
+ ctx.ServerError("GetProtectBranchOfRepoByID", err)
+ return
+ }
+ if protectBranch != nil && protectBranch.RuleName != f.RuleName {
+ // RuleName changed. We need to check if there is a rule with the same name.
+ // If a rule with the same name exists, an error should be returned.
+ sameNameProtectBranch, err := git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, f.RuleName)
+ if err != nil {
+ ctx.ServerError("GetProtectBranchOfRepoByName", err)
+ return
+ }
+ if sameNameProtectBranch != nil {
+ ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name"))
+ ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName))
+ return
+ }
+ }
+ } else {
+ // Check if a rule already exists with this rulename, if so redirect to it.
+ protectBranch, err = git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, f.RuleName)
+ if err != nil {
+ ctx.ServerError("GetProtectedBranchRuleByName", err)
+ return
+ }
+ if protectBranch != nil {
+ ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_duplicate_rule_name"))
+ ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName))
+ return
+ }
+ }
+ if protectBranch == nil {
+ // No options found, create defaults.
+ protectBranch = &git_model.ProtectedBranch{
+ RepoID: ctx.Repo.Repository.ID,
+ RuleName: f.RuleName,
+ }
+ }
+
+ var whitelistUsers, whitelistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64
+ protectBranch.RuleName = f.RuleName
+ if f.RequiredApprovals < 0 {
+ ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
+ ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, f.RuleName))
+ return
+ }
+
+ switch f.EnablePush {
+ case "all":
+ protectBranch.CanPush = true
+ protectBranch.EnableWhitelist = false
+ protectBranch.WhitelistDeployKeys = false
+ case "whitelist":
+ protectBranch.CanPush = true
+ protectBranch.EnableWhitelist = true
+ protectBranch.WhitelistDeployKeys = f.WhitelistDeployKeys
+ if strings.TrimSpace(f.WhitelistUsers) != "" {
+ whitelistUsers, _ = base.StringsToInt64s(strings.Split(f.WhitelistUsers, ","))
+ }
+ if strings.TrimSpace(f.WhitelistTeams) != "" {
+ whitelistTeams, _ = base.StringsToInt64s(strings.Split(f.WhitelistTeams, ","))
+ }
+ default:
+ protectBranch.CanPush = false
+ protectBranch.EnableWhitelist = false
+ protectBranch.WhitelistDeployKeys = false
+ }
+
+ protectBranch.EnableMergeWhitelist = f.EnableMergeWhitelist
+ if f.EnableMergeWhitelist {
+ if strings.TrimSpace(f.MergeWhitelistUsers) != "" {
+ mergeWhitelistUsers, _ = base.StringsToInt64s(strings.Split(f.MergeWhitelistUsers, ","))
+ }
+ if strings.TrimSpace(f.MergeWhitelistTeams) != "" {
+ mergeWhitelistTeams, _ = base.StringsToInt64s(strings.Split(f.MergeWhitelistTeams, ","))
+ }
+ }
+
+ protectBranch.EnableStatusCheck = f.EnableStatusCheck
+ if f.EnableStatusCheck {
+ patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n")
+ validPatterns := make([]string, 0, len(patterns))
+ for _, pattern := range patterns {
+ trimmed := strings.TrimSpace(pattern)
+ if trimmed == "" {
+ continue
+ }
+ if _, err := glob.Compile(trimmed); err != nil {
+ ctx.Flash.Error(ctx.Tr("repo.settings.protect_invalid_status_check_pattern", pattern))
+ ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName)))
+ return
+ }
+ validPatterns = append(validPatterns, trimmed)
+ }
+ if len(validPatterns) == 0 {
+ // if status check is enabled, patterns slice is not allowed to be empty
+ ctx.Flash.Error(ctx.Tr("repo.settings.protect_no_valid_status_check_patterns"))
+ ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit?rule_name=%s", ctx.Repo.RepoLink, url.QueryEscape(protectBranch.RuleName)))
+ return
+ }
+ protectBranch.StatusCheckContexts = validPatterns
+ } else {
+ protectBranch.StatusCheckContexts = nil
+ }
+
+ protectBranch.RequiredApprovals = f.RequiredApprovals
+ protectBranch.EnableApprovalsWhitelist = f.EnableApprovalsWhitelist
+ if f.EnableApprovalsWhitelist {
+ if strings.TrimSpace(f.ApprovalsWhitelistUsers) != "" {
+ approvalsWhitelistUsers, _ = base.StringsToInt64s(strings.Split(f.ApprovalsWhitelistUsers, ","))
+ }
+ if strings.TrimSpace(f.ApprovalsWhitelistTeams) != "" {
+ approvalsWhitelistTeams, _ = base.StringsToInt64s(strings.Split(f.ApprovalsWhitelistTeams, ","))
+ }
+ }
+ protectBranch.BlockOnRejectedReviews = f.BlockOnRejectedReviews
+ protectBranch.BlockOnOfficialReviewRequests = f.BlockOnOfficialReviewRequests
+ protectBranch.DismissStaleApprovals = f.DismissStaleApprovals
+ protectBranch.IgnoreStaleApprovals = f.IgnoreStaleApprovals
+ protectBranch.RequireSignedCommits = f.RequireSignedCommits
+ protectBranch.ProtectedFilePatterns = f.ProtectedFilePatterns
+ protectBranch.UnprotectedFilePatterns = f.UnprotectedFilePatterns
+ protectBranch.BlockOnOutdatedBranch = f.BlockOnOutdatedBranch
+ protectBranch.ApplyToAdmins = f.ApplyToAdmins
+
+ err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
+ UserIDs: whitelistUsers,
+ TeamIDs: whitelistTeams,
+ MergeUserIDs: mergeWhitelistUsers,
+ MergeTeamIDs: mergeWhitelistTeams,
+ ApprovalsUserIDs: approvalsWhitelistUsers,
+ ApprovalsTeamIDs: approvalsWhitelistTeams,
+ })
+ if err != nil {
+ ctx.ServerError("UpdateProtectBranch", err)
+ return
+ }
+
+ // FIXME: since we only need to recheck files protected rules, we could improve this
+ matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, protectBranch.RuleName)
+ if err != nil {
+ ctx.ServerError("FindAllMatchedBranches", err)
+ return
+ }
+ for _, branchName := range matchedBranches {
+ if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil {
+ ctx.ServerError("CheckPRsForBaseBranch", err)
+ return
+ }
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName))
+ ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName))
+}
+
+// DeleteProtectedBranchRulePost delete protected branch rule by id
+func DeleteProtectedBranchRulePost(ctx *context.Context) {
+ ruleID := ctx.ParamsInt64("id")
+ if ruleID <= 0 {
+ ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID)))
+ ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink))
+ return
+ }
+
+ rule, err := git_model.GetProtectedBranchRuleByID(ctx, ctx.Repo.Repository.ID, ruleID)
+ if err != nil {
+ ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID)))
+ ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink))
+ return
+ }
+
+ if rule == nil {
+ ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID)))
+ ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink))
+ return
+ }
+
+ if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, ruleID); err != nil {
+ ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", rule.RuleName))
+ ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink))
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName))
+ ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink))
+}
+
+// RenameBranchPost responses for rename a branch
+func RenameBranchPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RenameBranchForm)
+
+ if !ctx.Repo.CanCreateBranch() {
+ ctx.NotFound("RenameBranch", nil)
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.Flash.Error(ctx.GetErrMsg())
+ ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink))
+ return
+ }
+
+ msg, err := repository.RenameBranch(ctx, ctx.Repo.Repository, ctx.Doer, ctx.Repo.GitRepo, form.From, form.To)
+ if err != nil {
+ if errors.Is(err, git_model.ErrBranchIsProtected) {
+ ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_protected", form.To))
+ ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink))
+ } else if git_model.IsErrBranchAlreadyExists(err) {
+ ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.To))
+ ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink))
+ } else {
+ ctx.ServerError("RenameBranch", err)
+ }
+ return
+ }
+
+ if msg == "target_exist" {
+ ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_exist", form.To))
+ ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink))
+ return
+ }
+
+ if msg == "from_not_exist" {
+ ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_not_exist", form.From))
+ ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink))
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.rename_branch_success", form.From, form.To))
+ ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink))
+}
diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go
new file mode 100644
index 0000000..2c25b65
--- /dev/null
+++ b/routers/web/repo/setting/protected_tag.go
@@ -0,0 +1,188 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+const (
+ tplTags base.TplName = "repo/settings/tags"
+)
+
+// Tags render the page to protect tags
+func ProtectedTags(ctx *context.Context) {
+ if setTagsContext(ctx) != nil {
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplTags)
+}
+
+// NewProtectedTagPost handles creation of a protect tag
+func NewProtectedTagPost(ctx *context.Context) {
+ if setTagsContext(ctx) != nil {
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplTags)
+ return
+ }
+
+ repo := ctx.Repo.Repository
+ form := web.GetForm(ctx).(*forms.ProtectTagForm)
+
+ pt := &git_model.ProtectedTag{
+ RepoID: repo.ID,
+ NamePattern: strings.TrimSpace(form.NamePattern),
+ }
+
+ if strings.TrimSpace(form.AllowlistUsers) != "" {
+ pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ","))
+ }
+ if strings.TrimSpace(form.AllowlistTeams) != "" {
+ pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ","))
+ }
+
+ if err := git_model.InsertProtectedTag(ctx, pt); err != nil {
+ ctx.ServerError("InsertProtectedTag", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
+}
+
+// EditProtectedTag render the page to edit a protect tag
+func EditProtectedTag(ctx *context.Context) {
+ if setTagsContext(ctx) != nil {
+ return
+ }
+
+ ctx.Data["PageIsEditProtectedTag"] = true
+
+ pt := selectProtectedTagByContext(ctx)
+ if pt == nil {
+ return
+ }
+
+ ctx.Data["name_pattern"] = pt.NamePattern
+ ctx.Data["allowlist_users"] = strings.Join(base.Int64sToStrings(pt.AllowlistUserIDs), ",")
+ ctx.Data["allowlist_teams"] = strings.Join(base.Int64sToStrings(pt.AllowlistTeamIDs), ",")
+
+ ctx.HTML(http.StatusOK, tplTags)
+}
+
+// EditProtectedTagPost handles creation of a protect tag
+func EditProtectedTagPost(ctx *context.Context) {
+ if setTagsContext(ctx) != nil {
+ return
+ }
+
+ ctx.Data["PageIsEditProtectedTag"] = true
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplTags)
+ return
+ }
+
+ pt := selectProtectedTagByContext(ctx)
+ if pt == nil {
+ return
+ }
+
+ form := web.GetForm(ctx).(*forms.ProtectTagForm)
+
+ pt.NamePattern = strings.TrimSpace(form.NamePattern)
+ pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ","))
+ pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ","))
+
+ if err := git_model.UpdateProtectedTag(ctx, pt); err != nil {
+ ctx.ServerError("UpdateProtectedTag", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
+}
+
+// DeleteProtectedTagPost handles deletion of a protected tag
+func DeleteProtectedTagPost(ctx *context.Context) {
+ pt := selectProtectedTagByContext(ctx)
+ if pt == nil {
+ return
+ }
+
+ if err := git_model.DeleteProtectedTag(ctx, pt); err != nil {
+ ctx.ServerError("DeleteProtectedTag", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
+}
+
+func setTagsContext(ctx *context.Context) error {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.tags")
+ ctx.Data["PageIsSettingsTags"] = true
+
+ protectedTags, err := git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("GetProtectedTags", err)
+ return err
+ }
+ ctx.Data["ProtectedTags"] = protectedTags
+
+ users, err := access_model.GetRepoReaders(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError("Repo.Repository.GetReaders", err)
+ return err
+ }
+ ctx.Data["Users"] = users
+
+ if ctx.Repo.Owner.IsOrganization() {
+ teams, err := organization.OrgFromUser(ctx.Repo.Owner).TeamsWithAccessToRepo(ctx, ctx.Repo.Repository.ID, perm.AccessModeRead)
+ if err != nil {
+ ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err)
+ return err
+ }
+ ctx.Data["Teams"] = teams
+ }
+
+ return nil
+}
+
+func selectProtectedTagByContext(ctx *context.Context) *git_model.ProtectedTag {
+ id := ctx.FormInt64("id")
+ if id == 0 {
+ id = ctx.ParamsInt64(":id")
+ }
+
+ tag, err := git_model.GetProtectedTagByID(ctx, id)
+ if err != nil {
+ ctx.ServerError("GetProtectedTagByID", err)
+ return nil
+ }
+
+ if tag != nil && tag.RepoID == ctx.Repo.Repository.ID {
+ return tag
+ }
+
+ ctx.NotFound("", fmt.Errorf("ProtectedTag[%v] not associated to repository %v", id, ctx.Repo.Repository))
+
+ return nil
+}
diff --git a/routers/web/repo/setting/runners.go b/routers/web/repo/setting/runners.go
new file mode 100644
index 0000000..a47d3b4
--- /dev/null
+++ b/routers/web/repo/setting/runners.go
@@ -0,0 +1,187 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ actions_shared "code.gitea.io/gitea/routers/web/shared/actions"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ // TODO: Separate secrets from runners when layout is ready
+ tplRepoRunners base.TplName = "repo/settings/actions"
+ tplOrgRunners base.TplName = "org/settings/actions"
+ tplAdminRunners base.TplName = "admin/actions"
+ tplUserRunners base.TplName = "user/settings/actions"
+ tplRepoRunnerEdit base.TplName = "repo/settings/runner_edit"
+ tplOrgRunnerEdit base.TplName = "org/settings/runners_edit"
+ tplAdminRunnerEdit base.TplName = "admin/runners/edit"
+ tplUserRunnerEdit base.TplName = "user/settings/runner_edit"
+)
+
+type runnersCtx struct {
+ OwnerID int64
+ RepoID int64
+ IsRepo bool
+ IsOrg bool
+ IsAdmin bool
+ IsUser bool
+ RunnersTemplate base.TplName
+ RunnerEditTemplate base.TplName
+ RedirectLink string
+}
+
+func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
+ if ctx.Data["PageIsRepoSettings"] == true {
+ return &runnersCtx{
+ RepoID: ctx.Repo.Repository.ID,
+ OwnerID: 0,
+ IsRepo: true,
+ RunnersTemplate: tplRepoRunners,
+ RunnerEditTemplate: tplRepoRunnerEdit,
+ RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/",
+ }, nil
+ }
+
+ if ctx.Data["PageIsOrgSettings"] == true {
+ err := shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return nil, nil
+ }
+ return &runnersCtx{
+ RepoID: 0,
+ OwnerID: ctx.Org.Organization.ID,
+ IsOrg: true,
+ RunnersTemplate: tplOrgRunners,
+ RunnerEditTemplate: tplOrgRunnerEdit,
+ RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/",
+ }, nil
+ }
+
+ if ctx.Data["PageIsAdmin"] == true {
+ return &runnersCtx{
+ RepoID: 0,
+ OwnerID: 0,
+ IsAdmin: true,
+ RunnersTemplate: tplAdminRunners,
+ RunnerEditTemplate: tplAdminRunnerEdit,
+ RedirectLink: setting.AppSubURL + "/admin/actions/runners/",
+ }, nil
+ }
+
+ if ctx.Data["PageIsUserSettings"] == true {
+ return &runnersCtx{
+ OwnerID: ctx.Doer.ID,
+ RepoID: 0,
+ IsUser: true,
+ RunnersTemplate: tplUserRunners,
+ RunnerEditTemplate: tplUserRunnerEdit,
+ RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/",
+ }, nil
+ }
+
+ return nil, errors.New("unable to set Runners context")
+}
+
+// Runners render settings/actions/runners page for repo level
+func Runners(ctx *context.Context) {
+ ctx.Data["PageIsSharedSettingsRunners"] = true
+ ctx.Data["Title"] = ctx.Tr("actions.actions")
+ ctx.Data["PageType"] = "runners"
+
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+
+ opts := actions_model.FindRunnerOptions{
+ ListOptions: db.ListOptions{
+ Page: page,
+ PageSize: 100,
+ },
+ Sort: ctx.Req.URL.Query().Get("sort"),
+ Filter: ctx.Req.URL.Query().Get("q"),
+ }
+ if rCtx.IsRepo {
+ opts.RepoID = rCtx.RepoID
+ opts.WithAvailable = true
+ } else if rCtx.IsOrg || rCtx.IsUser {
+ opts.OwnerID = rCtx.OwnerID
+ opts.WithAvailable = true
+ }
+ actions_shared.RunnersList(ctx, opts)
+
+ ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
+}
+
+// RunnersEdit renders runner edit page for repository level
+func RunnersEdit(ctx *context.Context) {
+ ctx.Data["PageIsSharedSettingsRunners"] = true
+ ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner")
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+
+ actions_shared.RunnerDetails(ctx, page,
+ ctx.ParamsInt64(":runnerid"), rCtx.OwnerID, rCtx.RepoID,
+ )
+ ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate)
+}
+
+func RunnersEditPost(ctx *context.Context) {
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+ actions_shared.RunnerDetailsEditPost(ctx, ctx.ParamsInt64(":runnerid"),
+ rCtx.OwnerID, rCtx.RepoID,
+ rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid")))
+}
+
+func ResetRunnerRegistrationToken(ctx *context.Context) {
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+ actions_shared.RunnerResetRegistrationToken(ctx, rCtx.OwnerID, rCtx.RepoID, rCtx.RedirectLink)
+}
+
+// RunnerDeletePost response for deleting runner
+func RunnerDeletePost(ctx *context.Context) {
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+ actions_shared.RunnerDeletePost(ctx, ctx.ParamsInt64(":runnerid"), rCtx.RedirectLink, rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid")))
+}
+
+func RedirectToDefaultSetting(ctx *context.Context) {
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners")
+}
diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go
new file mode 100644
index 0000000..d4d56bf
--- /dev/null
+++ b/routers/web/repo/setting/secrets.go
@@ -0,0 +1,127 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ shared "code.gitea.io/gitea/routers/web/shared/secrets"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ // TODO: Separate secrets from runners when layout is ready
+ tplRepoSecrets base.TplName = "repo/settings/actions"
+ tplOrgSecrets base.TplName = "org/settings/actions"
+ tplUserSecrets base.TplName = "user/settings/actions"
+)
+
+type secretsCtx struct {
+ OwnerID int64
+ RepoID int64
+ IsRepo bool
+ IsOrg bool
+ IsUser bool
+ SecretsTemplate base.TplName
+ RedirectLink string
+}
+
+func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) {
+ if ctx.Data["PageIsRepoSettings"] == true {
+ return &secretsCtx{
+ OwnerID: 0,
+ RepoID: ctx.Repo.Repository.ID,
+ IsRepo: true,
+ SecretsTemplate: tplRepoSecrets,
+ RedirectLink: ctx.Repo.RepoLink + "/settings/actions/secrets",
+ }, nil
+ }
+
+ if ctx.Data["PageIsOrgSettings"] == true {
+ err := shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return nil, nil
+ }
+ return &secretsCtx{
+ OwnerID: ctx.ContextUser.ID,
+ RepoID: 0,
+ IsOrg: true,
+ SecretsTemplate: tplOrgSecrets,
+ RedirectLink: ctx.Org.OrgLink + "/settings/actions/secrets",
+ }, nil
+ }
+
+ if ctx.Data["PageIsUserSettings"] == true {
+ return &secretsCtx{
+ OwnerID: ctx.Doer.ID,
+ RepoID: 0,
+ IsUser: true,
+ SecretsTemplate: tplUserSecrets,
+ RedirectLink: setting.AppSubURL + "/user/settings/actions/secrets",
+ }, nil
+ }
+
+ return nil, errors.New("unable to set Secrets context")
+}
+
+func Secrets(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("actions.actions")
+ ctx.Data["PageType"] = "secrets"
+ ctx.Data["PageIsSharedSettingsSecrets"] = true
+
+ sCtx, err := getSecretsCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getSecretsCtx", err)
+ return
+ }
+
+ if sCtx.IsRepo {
+ ctx.Data["DisableSSH"] = setting.SSH.Disabled
+ }
+
+ shared.SetSecretsContext(ctx, sCtx.OwnerID, sCtx.RepoID)
+ if ctx.Written() {
+ return
+ }
+ ctx.HTML(http.StatusOK, sCtx.SecretsTemplate)
+}
+
+func SecretsPost(ctx *context.Context) {
+ sCtx, err := getSecretsCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getSecretsCtx", err)
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.JSONError(ctx.GetErrMsg())
+ return
+ }
+
+ shared.PerformSecretsPost(
+ ctx,
+ sCtx.OwnerID,
+ sCtx.RepoID,
+ sCtx.RedirectLink,
+ )
+}
+
+func SecretsDelete(ctx *context.Context) {
+ sCtx, err := getSecretsCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getSecretsCtx", err)
+ return
+ }
+ shared.PerformSecretsDelete(
+ ctx,
+ sCtx.OwnerID,
+ sCtx.RepoID,
+ sCtx.RedirectLink,
+ )
+}
diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go
new file mode 100644
index 0000000..ce506ea
--- /dev/null
+++ b/routers/web/repo/setting/setting.go
@@ -0,0 +1,1113 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ quota_model "code.gitea.io/gitea/models/quota"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/indexer/code"
+ "code.gitea.io/gitea/modules/indexer/stats"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/validation"
+ "code.gitea.io/gitea/modules/web"
+ actions_service "code.gitea.io/gitea/services/actions"
+ asymkey_service "code.gitea.io/gitea/services/asymkey"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/federation"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/migrations"
+ mirror_service "code.gitea.io/gitea/services/mirror"
+ repo_service "code.gitea.io/gitea/services/repository"
+ wiki_service "code.gitea.io/gitea/services/wiki"
+)
+
+const (
+ tplSettingsOptions base.TplName = "repo/settings/options"
+ tplSettingsUnits base.TplName = "repo/settings/units"
+ tplCollaboration base.TplName = "repo/settings/collaboration"
+ tplBranches base.TplName = "repo/settings/branches"
+ tplGithooks base.TplName = "repo/settings/githooks"
+ tplGithookEdit base.TplName = "repo/settings/githook_edit"
+ tplDeployKeys base.TplName = "repo/settings/deploy_keys"
+)
+
+// SettingsCtxData is a middleware that sets all the general context data for the
+// settings template.
+func SettingsCtxData(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.options")
+ ctx.Data["PageIsSettingsOptions"] = true
+ ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate
+ ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled
+ ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
+ ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush
+ ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval
+ ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval
+
+ signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath())
+ ctx.Data["SigningKeyAvailable"] = len(signing) > 0
+ ctx.Data["SigningSettings"] = setting.Repository.Signing
+ ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+
+ if ctx.Doer.IsAdmin {
+ if setting.Indexer.RepoIndexerEnabled {
+ status, err := repo_model.GetIndexerStatus(ctx, ctx.Repo.Repository, repo_model.RepoIndexerTypeCode)
+ if err != nil {
+ ctx.ServerError("repo.indexer_status", err)
+ return
+ }
+ ctx.Data["CodeIndexerStatus"] = status
+ }
+ status, err := repo_model.GetIndexerStatus(ctx, ctx.Repo.Repository, repo_model.RepoIndexerTypeStats)
+ if err != nil {
+ ctx.ServerError("repo.indexer_status", err)
+ return
+ }
+ ctx.Data["StatsIndexerStatus"] = status
+ }
+ pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, ctx.Repo.Repository.ID, db.ListOptions{})
+ if err != nil {
+ ctx.ServerError("GetPushMirrorsByRepoID", err)
+ return
+ }
+ ctx.Data["PushMirrors"] = pushMirrors
+ ctx.Data["CanUseSSHMirroring"] = git.HasSSHExecutable
+}
+
+// Units show a repositorys unit settings page
+func Units(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.units.units")
+ ctx.Data["PageIsRepoSettingsUnits"] = true
+
+ ctx.HTML(http.StatusOK, tplSettingsUnits)
+}
+
+func UnitsPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RepoUnitSettingForm)
+
+ repo := ctx.Repo.Repository
+
+ var repoChanged bool
+ var units []repo_model.RepoUnit
+ var deleteUnitTypes []unit_model.Type
+
+ // This section doesn't require repo_name/RepoName to be set in the form, don't show it
+ // as an error on the UI for this action
+ ctx.Data["Err_RepoName"] = nil
+
+ if repo.CloseIssuesViaCommitInAnyBranch != form.EnableCloseIssuesViaCommitInAnyBranch {
+ repo.CloseIssuesViaCommitInAnyBranch = form.EnableCloseIssuesViaCommitInAnyBranch
+ repoChanged = true
+ }
+
+ if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeCode,
+ })
+ } else if !unit_model.TypeCode.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode)
+ }
+
+ if form.EnableWiki && form.EnableExternalWiki && !unit_model.TypeExternalWiki.UnitGlobalDisabled() {
+ if !validation.IsValidExternalURL(form.ExternalWikiURL) {
+ ctx.Flash.Error(ctx.Tr("repo.settings.external_wiki_url_error"))
+ ctx.Redirect(repo.Link() + "/settings/units")
+ return
+ }
+
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeExternalWiki,
+ Config: &repo_model.ExternalWikiConfig{
+ ExternalWikiURL: form.ExternalWikiURL,
+ },
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
+ } else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() {
+ var wikiPermissions repo_model.UnitAccessMode
+ if form.GloballyWriteableWiki {
+ wikiPermissions = repo_model.UnitAccessModeWrite
+ } else {
+ wikiPermissions = repo_model.UnitAccessModeRead
+ }
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeWiki,
+ Config: new(repo_model.UnitConfig),
+ DefaultPermissions: wikiPermissions,
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki)
+ } else {
+ if !unit_model.TypeExternalWiki.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki)
+ }
+ if !unit_model.TypeWiki.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
+ }
+ }
+
+ if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
+ if !validation.IsValidExternalURL(form.ExternalTrackerURL) {
+ ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error"))
+ ctx.Redirect(repo.Link() + "/settings/units")
+ return
+ }
+ if len(form.TrackerURLFormat) != 0 && !validation.IsValidExternalTrackerURLFormat(form.TrackerURLFormat) {
+ ctx.Flash.Error(ctx.Tr("repo.settings.tracker_url_format_error"))
+ ctx.Redirect(repo.Link() + "/settings/units")
+ return
+ }
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeExternalTracker,
+ Config: &repo_model.ExternalTrackerConfig{
+ ExternalTrackerURL: form.ExternalTrackerURL,
+ ExternalTrackerFormat: form.TrackerURLFormat,
+ ExternalTrackerStyle: form.TrackerIssueStyle,
+ ExternalTrackerRegexpPattern: form.ExternalTrackerRegexpPattern,
+ },
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
+ } else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeIssues,
+ Config: &repo_model.IssuesConfig{
+ EnableTimetracker: form.EnableTimetracker,
+ AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime,
+ EnableDependencies: form.EnableIssueDependencies,
+ },
+ })
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker)
+ } else {
+ if !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker)
+ }
+ if !unit_model.TypeIssues.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
+ }
+ }
+
+ if form.EnableProjects && !unit_model.TypeProjects.UnitGlobalDisabled() {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeProjects,
+ })
+ } else if !unit_model.TypeProjects.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects)
+ }
+
+ if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeReleases,
+ })
+ } else if !unit_model.TypeReleases.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases)
+ }
+
+ if form.EnablePackages && !unit_model.TypePackages.UnitGlobalDisabled() {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypePackages,
+ })
+ } else if !unit_model.TypePackages.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages)
+ }
+
+ if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeActions,
+ })
+ } else if !unit_model.TypeActions.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions)
+ }
+
+ if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() {
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypePullRequests,
+ Config: &repo_model.PullRequestsConfig{
+ IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace,
+ AllowMerge: form.PullsAllowMerge,
+ AllowRebase: form.PullsAllowRebase,
+ AllowRebaseMerge: form.PullsAllowRebaseMerge,
+ AllowSquash: form.PullsAllowSquash,
+ AllowFastForwardOnly: form.PullsAllowFastForwardOnly,
+ AllowManualMerge: form.PullsAllowManualMerge,
+ AutodetectManualMerge: form.EnableAutodetectManualMerge,
+ AllowRebaseUpdate: form.PullsAllowRebaseUpdate,
+ DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge,
+ DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle),
+ DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit,
+ },
+ })
+ } else if !unit_model.TypePullRequests.UnitGlobalDisabled() {
+ deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests)
+ }
+
+ if len(units) == 0 {
+ ctx.Flash.Error(ctx.Tr("repo.settings.update_settings_no_unit"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/units")
+ return
+ }
+
+ if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil {
+ ctx.ServerError("UpdateRepositoryUnits", err)
+ return
+ }
+ if repoChanged {
+ if err := repo_service.UpdateRepository(ctx, repo, false); err != nil {
+ ctx.ServerError("UpdateRepository", err)
+ return
+ }
+ }
+ log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings/units")
+}
+
+// Settings show a repository's settings page
+func Settings(ctx *context.Context) {
+ ctx.HTML(http.StatusOK, tplSettingsOptions)
+}
+
+// SettingsPost response for changes of a repository
+func SettingsPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RepoSettingForm)
+
+ ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate
+ ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled
+ ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
+ ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush
+ ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval
+ ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval
+
+ signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath())
+ ctx.Data["SigningKeyAvailable"] = len(signing) > 0
+ ctx.Data["SigningSettings"] = setting.Repository.Signing
+ ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+
+ repo := ctx.Repo.Repository
+
+ switch ctx.FormString("action") {
+ case "update":
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSettingsOptions)
+ return
+ }
+
+ newRepoName := form.RepoName
+ // Check if repository name has been changed.
+ if repo.LowerName != strings.ToLower(newRepoName) {
+ // Close the GitRepo if open
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ ctx.Repo.GitRepo = nil
+ }
+ if err := repo_service.ChangeRepositoryName(ctx, ctx.Doer, repo, newRepoName); err != nil {
+ ctx.Data["Err_RepoName"] = true
+ switch {
+ case repo_model.IsErrRepoAlreadyExist(err):
+ ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form)
+ case db.IsErrNameReserved(err):
+ ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form)
+ case repo_model.IsErrRepoFilesAlreadyExist(err):
+ ctx.Data["Err_RepoName"] = true
+ switch {
+ case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories):
+ ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplSettingsOptions, form)
+ case setting.Repository.AllowAdoptionOfUnadoptedRepositories:
+ ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplSettingsOptions, form)
+ case setting.Repository.AllowDeleteOfUnadoptedRepositories:
+ ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplSettingsOptions, form)
+ default:
+ ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplSettingsOptions, form)
+ }
+ case db.IsErrNamePatternNotAllowed(err):
+ ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form)
+ default:
+ ctx.ServerError("ChangeRepositoryName", err)
+ }
+ return
+ }
+
+ log.Trace("Repository name changed: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newRepoName)
+ }
+ // In case it's just a case change.
+ repo.Name = newRepoName
+ repo.LowerName = strings.ToLower(newRepoName)
+ repo.Description = form.Description
+ repo.Website = form.Website
+ repo.IsTemplate = form.Template
+
+ // Visibility of forked repository is forced sync with base repository.
+ if repo.IsFork {
+ form.Private = repo.BaseRepo.IsPrivate || repo.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate
+ }
+
+ visibilityChanged := repo.IsPrivate != form.Private
+ // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public
+ if visibilityChanged && setting.Repository.ForcePrivate && !form.Private && !ctx.Doer.IsAdmin {
+ ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form)
+ return
+ }
+
+ repo.IsPrivate = form.Private
+ if err := repo_service.UpdateRepository(ctx, repo, visibilityChanged); err != nil {
+ ctx.ServerError("UpdateRepository", err)
+ return
+ }
+ log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "federation":
+ if !setting.Federation.Enabled {
+ ctx.NotFound("", nil)
+ ctx.Flash.Info(ctx.Tr("repo.settings.federation_not_enabled"))
+ return
+ }
+ followingRepos := strings.TrimSpace(form.FollowingRepos)
+ followingRepos = strings.TrimSuffix(followingRepos, ";")
+
+ maxFollowingRepoStrLength := 2048
+ errs := validation.ValidateMaxLen(followingRepos, maxFollowingRepoStrLength, "federationRepos")
+ if len(errs) > 0 {
+ ctx.Data["ERR_FollowingRepos"] = true
+ ctx.Flash.Error(ctx.Tr("repo.form.string_too_long", maxFollowingRepoStrLength))
+ ctx.Redirect(repo.Link() + "/settings")
+ return
+ }
+
+ federationRepoSplit := []string{}
+ if followingRepos != "" {
+ federationRepoSplit = strings.Split(followingRepos, ";")
+ }
+ for idx, repo := range federationRepoSplit {
+ federationRepoSplit[idx] = strings.TrimSpace(repo)
+ }
+
+ if _, _, err := federation.StoreFollowingRepoList(ctx, ctx.Repo.Repository.ID, federationRepoSplit); err != nil {
+ ctx.ServerError("UpdateRepository", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "mirror":
+ if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ pullMirror, err := repo_model.GetMirrorByRepoID(ctx, ctx.Repo.Repository.ID)
+ if err == repo_model.ErrMirrorNotExist {
+ ctx.NotFound("", nil)
+ return
+ }
+ if err != nil {
+ ctx.ServerError("GetMirrorByRepoID", err)
+ return
+ }
+ // This section doesn't require repo_name/RepoName to be set in the form, don't show it
+ // as an error on the UI for this action
+ ctx.Data["Err_RepoName"] = nil
+
+ interval, err := time.ParseDuration(form.Interval)
+ if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) {
+ ctx.Data["Err_Interval"] = true
+ ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form)
+ return
+ }
+
+ pullMirror.EnablePrune = form.EnablePrune
+ pullMirror.Interval = interval
+ pullMirror.ScheduleNextUpdate()
+ if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil {
+ ctx.ServerError("UpdateMirror", err)
+ return
+ }
+
+ u, err := git.GetRemoteURL(ctx, ctx.Repo.Repository.RepoPath(), pullMirror.GetRemoteName())
+ if err != nil {
+ ctx.Data["Err_MirrorAddress"] = true
+ handleSettingRemoteAddrError(ctx, err, form)
+ return
+ }
+ if u.User != nil && form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() {
+ form.MirrorPassword, _ = u.User.Password()
+ }
+
+ address, err := forms.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword)
+ if err == nil {
+ err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
+ }
+ if err != nil {
+ ctx.Data["Err_MirrorAddress"] = true
+ handleSettingRemoteAddrError(ctx, err, form)
+ return
+ }
+
+ if err := mirror_service.UpdateAddress(ctx, pullMirror, address); err != nil {
+ ctx.ServerError("UpdateAddress", err)
+ return
+ }
+ remoteAddress, err := util.SanitizeURL(address)
+ if err != nil {
+ ctx.Data["Err_MirrorAddress"] = true
+ handleSettingRemoteAddrError(ctx, err, form)
+ return
+ }
+ pullMirror.RemoteAddress = remoteAddress
+
+ form.LFS = form.LFS && setting.LFS.StartServer
+
+ if len(form.LFSEndpoint) > 0 {
+ ep := lfs.DetermineEndpoint("", form.LFSEndpoint)
+ if ep == nil {
+ ctx.Data["Err_LFSEndpoint"] = true
+ ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_lfs_endpoint"), tplSettingsOptions, &form)
+ return
+ }
+ err = migrations.IsMigrateURLAllowed(ep.String(), ctx.Doer)
+ if err != nil {
+ ctx.Data["Err_LFSEndpoint"] = true
+ handleSettingRemoteAddrError(ctx, err, form)
+ return
+ }
+ }
+
+ pullMirror.LFS = form.LFS
+ pullMirror.LFSEndpoint = form.LFSEndpoint
+ if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil {
+ ctx.ServerError("UpdateMirror", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "mirror-sync":
+ if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ ok, err := quota_model.EvaluateForUser(ctx, repo.OwnerID, quota_model.LimitSubjectSizeReposAll)
+ if err != nil {
+ ctx.ServerError("quota_model.EvaluateForUser", err)
+ return
+ }
+ if !ok {
+ // This section doesn't require repo_name/RepoName to be set in the form, don't show it
+ // as an error on the UI for this action
+ ctx.Data["Err_RepoName"] = nil
+
+ ctx.RenderWithErr(ctx.Tr("repo.settings.pull_mirror_sync_quota_exceeded"), tplSettingsOptions, &form)
+ return
+ }
+
+ mirror_service.AddPullMirrorToQueue(repo.ID)
+
+ ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "push-mirror-sync":
+ if !setting.Mirror.Enabled {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ m, err := selectPushMirrorByForm(ctx, form, repo)
+ if err != nil {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ mirror_service.AddPushMirrorToQueue(m.ID)
+
+ ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "push-mirror-update":
+ if !setting.Mirror.Enabled || repo.IsArchived {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ // This section doesn't require repo_name/RepoName to be set in the form, don't show it
+ // as an error on the UI for this action
+ ctx.Data["Err_RepoName"] = nil
+
+ m, err := selectPushMirrorByForm(ctx, form, repo)
+ if err != nil {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ interval, err := time.ParseDuration(form.PushMirrorInterval)
+ if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) {
+ ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &forms.RepoSettingForm{})
+ return
+ }
+
+ m.Interval = interval
+ if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil {
+ ctx.ServerError("UpdatePushMirrorInterval", err)
+ return
+ }
+ // Background why we are adding it to Queue
+ // If we observed its implementation in the context of `push-mirror-sync` where it
+ // is evident that pushing to the queue is necessary for updates.
+ // So, there are updates within the given interval, it is necessary to update the queue accordingly.
+ mirror_service.AddPushMirrorToQueue(m.ID)
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "push-mirror-remove":
+ if !setting.Mirror.Enabled || repo.IsArchived {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ // This section doesn't require repo_name/RepoName to be set in the form, don't show it
+ // as an error on the UI for this action
+ ctx.Data["Err_RepoName"] = nil
+
+ m, err := selectPushMirrorByForm(ctx, form, repo)
+ if err != nil {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ if err = mirror_service.RemovePushMirrorRemote(ctx, m); err != nil {
+ ctx.ServerError("RemovePushMirrorRemote", err)
+ return
+ }
+
+ if err = repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
+ ctx.ServerError("DeletePushMirrorByID", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "push-mirror-add":
+ if setting.Mirror.DisableNewPush || repo.IsArchived {
+ ctx.NotFound("", nil)
+ return
+ }
+
+ // This section doesn't require repo_name/RepoName to be set in the form, don't show it
+ // as an error on the UI for this action
+ ctx.Data["Err_RepoName"] = nil
+
+ interval, err := time.ParseDuration(form.PushMirrorInterval)
+ if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) {
+ ctx.Data["Err_PushMirrorInterval"] = true
+ ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form)
+ return
+ }
+
+ if form.PushMirrorUseSSH && (form.PushMirrorUsername != "" || form.PushMirrorPassword != "") {
+ ctx.Data["Err_PushMirrorUseSSH"] = true
+ ctx.RenderWithErr(ctx.Tr("repo.mirror_denied_combination"), tplSettingsOptions, &form)
+ return
+ }
+
+ if form.PushMirrorUseSSH && !git.HasSSHExecutable {
+ ctx.RenderWithErr(ctx.Tr("repo.mirror_use_ssh.not_available"), tplSettingsOptions, &form)
+ return
+ }
+
+ address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword)
+ if err == nil {
+ err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
+ }
+ if err != nil {
+ ctx.Data["Err_PushMirrorAddress"] = true
+ handleSettingRemoteAddrError(ctx, err, form)
+ return
+ }
+
+ remoteSuffix, err := util.CryptoRandomString(10)
+ if err != nil {
+ ctx.ServerError("RandomString", err)
+ return
+ }
+
+ remoteAddress, err := util.SanitizeURL(address)
+ if err != nil {
+ ctx.Data["Err_PushMirrorAddress"] = true
+ handleSettingRemoteAddrError(ctx, err, form)
+ return
+ }
+
+ m := &repo_model.PushMirror{
+ RepoID: repo.ID,
+ Repo: repo,
+ RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix),
+ SyncOnCommit: form.PushMirrorSyncOnCommit,
+ Interval: interval,
+ RemoteAddress: remoteAddress,
+ }
+
+ var plainPrivateKey []byte
+ if form.PushMirrorUseSSH {
+ publicKey, privateKey, err := util.GenerateSSHKeypair()
+ if err != nil {
+ ctx.ServerError("GenerateSSHKeypair", err)
+ return
+ }
+ plainPrivateKey = privateKey
+ m.PublicKey = string(publicKey)
+ }
+
+ if err := db.Insert(ctx, m); err != nil {
+ ctx.ServerError("InsertPushMirror", err)
+ return
+ }
+
+ if form.PushMirrorUseSSH {
+ if err := m.SetPrivatekey(ctx, plainPrivateKey); err != nil {
+ ctx.ServerError("SetPrivatekey", err)
+ return
+ }
+ }
+
+ if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil {
+ if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
+ log.Error("DeletePushMirrors %v", err)
+ }
+ ctx.ServerError("AddPushMirrorRemote", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "signing":
+ changed := false
+ trustModel := repo_model.ToTrustModel(form.TrustModel)
+ if trustModel != repo.TrustModel {
+ repo.TrustModel = trustModel
+ changed = true
+ }
+
+ if changed {
+ if err := repo_service.UpdateRepository(ctx, repo, false); err != nil {
+ ctx.ServerError("UpdateRepository", err)
+ return
+ }
+ }
+ log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+
+ case "admin":
+ if !ctx.Doer.IsAdmin {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if repo.IsFsckEnabled != form.EnableHealthCheck {
+ repo.IsFsckEnabled = form.EnableHealthCheck
+ }
+
+ if err := repo_service.UpdateRepository(ctx, repo, false); err != nil {
+ ctx.ServerError("UpdateRepository", err)
+ return
+ }
+
+ log.Trace("Repository admin settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+
+ case "admin_index":
+ if !ctx.Doer.IsAdmin {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ switch form.RequestReindexType {
+ case "stats":
+ if err := stats.UpdateRepoIndexer(ctx.Repo.Repository); err != nil {
+ ctx.ServerError("UpdateStatsRepondexer", err)
+ return
+ }
+ case "code":
+ if !setting.Indexer.RepoIndexerEnabled {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+ code.UpdateRepoIndexer(ctx.Repo.Repository)
+ default:
+ ctx.NotFound("", nil)
+ return
+ }
+
+ log.Trace("Repository reindex for %s requested: %s/%s", form.RequestReindexType, ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.reindex_requested"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+
+ case "convert":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if repo.FullName() != form.RepoName {
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
+ return
+ }
+
+ if !repo.IsMirror {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ repo.IsMirror = false
+
+ if _, err := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil {
+ ctx.ServerError("CleanUpMigrateInfo", err)
+ return
+ } else if err = repo_model.DeleteMirrorByRepoID(ctx, ctx.Repo.Repository.ID); err != nil {
+ ctx.ServerError("DeleteMirrorByRepoID", err)
+ return
+ }
+ log.Trace("Repository converted from mirror to regular: %s", repo.FullName())
+ ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed"))
+ ctx.Redirect(repo.Link())
+
+ case "convert_fork":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if err := repo.LoadOwner(ctx); err != nil {
+ ctx.ServerError("Convert Fork", err)
+ return
+ }
+ if repo.FullName() != form.RepoName {
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
+ return
+ }
+
+ if !repo.IsFork {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+
+ if !ctx.Repo.Owner.CanCreateRepo() {
+ maxCreationLimit := ctx.Repo.Owner.MaxCreationLimit()
+ msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit)
+ ctx.Flash.Error(msg)
+ ctx.Redirect(repo.Link() + "/settings")
+ return
+ }
+
+ if err := repo_service.ConvertForkToNormalRepository(ctx, repo); err != nil {
+ log.Error("Unable to convert repository %-v from fork. Error: %v", repo, err)
+ ctx.ServerError("Convert Fork", err)
+ return
+ }
+
+ log.Trace("Repository converted from fork to regular: %s", repo.FullName())
+ ctx.Flash.Success(ctx.Tr("repo.settings.convert_fork_succeed"))
+ ctx.Redirect(repo.Link())
+
+ case "transfer":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if repo.FullName() != form.RepoName {
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
+ return
+ }
+
+ newOwner, err := user_model.GetUserByName(ctx, ctx.FormString("new_owner_name"))
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
+ return
+ }
+ ctx.ServerError("IsUserExist", err)
+ return
+ }
+
+ if newOwner.Type == user_model.UserTypeOrganization {
+ if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) {
+ // The user shouldn't know about this organization
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil)
+ return
+ }
+ }
+
+ // Check the quota of the new owner
+ ok, err := quota_model.EvaluateForUser(ctx, newOwner.ID, quota_model.LimitSubjectSizeReposAll)
+ if err != nil {
+ ctx.ServerError("quota_model.EvaluateForUser", err)
+ return
+ }
+ if !ok {
+ ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_quota_exceeded", newOwner.Name), tplSettingsOptions, &form)
+ return
+ }
+
+ // Close the GitRepo if open
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ ctx.Repo.GitRepo = nil
+ }
+
+ oldFullname := repo.FullName()
+ if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil {
+ if errors.Is(err, user_model.ErrBlockedByUser) {
+ ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_blocked_doer"), tplSettingsOptions, nil)
+ } else if repo_model.IsErrRepoAlreadyExist(err) {
+ ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil)
+ } else if models.IsErrRepoTransferInProgress(err) {
+ ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil)
+ } else {
+ ctx.ServerError("TransferOwnership", err)
+ }
+
+ return
+ }
+
+ if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
+ log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner)
+ ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName()))
+ } else {
+ log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName())
+ ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed"))
+ }
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "cancel_transfer":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+
+ repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
+ if err != nil {
+ if models.IsErrNoPendingTransfer(err) {
+ ctx.Flash.Error("repo.settings.transfer_abort_invalid")
+ ctx.Redirect(repo.Link() + "/settings")
+ } else {
+ ctx.ServerError("GetPendingRepositoryTransfer", err)
+ }
+
+ return
+ }
+
+ if err := repoTransfer.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LoadRecipient", err)
+ return
+ }
+
+ if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil {
+ ctx.ServerError("CancelRepositoryTransfer", err)
+ return
+ }
+
+ log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name)
+ ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name))
+ ctx.Redirect(repo.Link() + "/settings")
+
+ case "delete":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if repo.FullName() != form.RepoName {
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
+ return
+ }
+
+ // Close the gitrepository before doing this.
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ }
+
+ if err := repo_service.DeleteRepository(ctx, ctx.Doer, ctx.Repo.Repository, true); err != nil {
+ ctx.ServerError("DeleteRepository", err)
+ return
+ }
+ log.Trace("Repository deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success"))
+ ctx.Redirect(ctx.Repo.Owner.DashboardLink())
+
+ case "delete-wiki":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if repo.FullName() != form.RepoName {
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
+ return
+ }
+
+ err := wiki_service.DeleteWiki(ctx, repo)
+ if err != nil {
+ log.Error("Delete Wiki: %v", err.Error())
+ }
+ log.Trace("Repository wiki deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+
+ case "rename-wiki-branch":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+ if repo.FullName() != form.RepoName {
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil)
+ return
+ }
+
+ if err := wiki_service.NormalizeWikiBranch(ctx, repo, setting.Repository.DefaultBranch); err != nil {
+ log.Error("Normalize Wiki branch: %v", err.Error())
+ ctx.Flash.Error(ctx.Tr("repo.settings.wiki_branch_rename_failure"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+ return
+ }
+ log.Trace("Repository wiki normalized: %s#%s", repo.FullName(), setting.Repository.DefaultBranch)
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.wiki_branch_rename_success"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+
+ case "archive":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if repo.IsMirror {
+ ctx.Flash.Error(ctx.Tr("repo.settings.archive.error_ismirror"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+ return
+ }
+
+ if err := repo_model.SetArchiveRepoState(ctx, repo, true); err != nil {
+ log.Error("Tried to archive a repo: %s", err)
+ ctx.Flash.Error(ctx.Tr("repo.settings.archive.error"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+ return
+ }
+
+ if err := actions_model.CleanRepoScheduleTasks(ctx, repo, true); err != nil {
+ log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err)
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.archive.success"))
+
+ log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+
+ case "unarchive":
+ if !ctx.Repo.IsOwner() {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if err := repo_model.SetArchiveRepoState(ctx, repo, false); err != nil {
+ log.Error("Tried to unarchive a repo: %s", err)
+ ctx.Flash.Error(ctx.Tr("repo.settings.unarchive.error"))
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+ return
+ }
+
+ if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) {
+ if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil {
+ log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err)
+ }
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success"))
+
+ log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
+ ctx.Redirect(ctx.Repo.RepoLink + "/settings")
+
+ default:
+ ctx.NotFound("", nil)
+ }
+}
+
+func handleSettingRemoteAddrError(ctx *context.Context, err error, form *forms.RepoSettingForm) {
+ if models.IsErrInvalidCloneAddr(err) {
+ addrErr := err.(*models.ErrInvalidCloneAddr)
+ switch {
+ case addrErr.IsProtocolInvalid:
+ ctx.RenderWithErr(ctx.Tr("repo.mirror_address_protocol_invalid"), tplSettingsOptions, form)
+ case addrErr.IsURLError:
+ ctx.RenderWithErr(ctx.Tr("form.url_error", addrErr.Host), tplSettingsOptions, form)
+ case addrErr.IsPermissionDenied:
+ if addrErr.LocalPath {
+ ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied"), tplSettingsOptions, form)
+ } else {
+ ctx.RenderWithErr(ctx.Tr("repo.migrate.permission_denied_blocked"), tplSettingsOptions, form)
+ }
+ case addrErr.IsInvalidPath:
+ ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_local_path"), tplSettingsOptions, form)
+ default:
+ ctx.ServerError("Unknown error", err)
+ }
+ return
+ }
+ ctx.RenderWithErr(ctx.Tr("repo.mirror_address_url_invalid"), tplSettingsOptions, form)
+}
+
+func selectPushMirrorByForm(ctx *context.Context, form *forms.RepoSettingForm, repo *repo_model.Repository) (*repo_model.PushMirror, error) {
+ id, err := strconv.ParseInt(form.PushMirrorID, 10, 64)
+ if err != nil {
+ return nil, err
+ }
+
+ pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ for _, m := range pushMirrors {
+ if m.ID == id {
+ m.Repo = repo
+ return m, nil
+ }
+ }
+
+ return nil, fmt.Errorf("PushMirror[%v] not associated to repository %v", id, repo)
+}
diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go
new file mode 100644
index 0000000..0c8553f
--- /dev/null
+++ b/routers/web/repo/setting/settings_test.go
@@ -0,0 +1,412 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+ "testing"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ 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/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/contexttest"
+ "code.gitea.io/gitea/services/forms"
+ repo_service "code.gitea.io/gitea/services/repository"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createSSHAuthorizedKeysTmpPath(t *testing.T) func() {
+ tmpDir := t.TempDir()
+
+ oldPath := setting.SSH.RootPath
+ setting.SSH.RootPath = tmpDir
+
+ return func() {
+ setting.SSH.RootPath = oldPath
+ }
+}
+
+func TestAddReadOnlyDeployKey(t *testing.T) {
+ if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil {
+ defer deferable()
+ } else {
+ return
+ }
+ unittest.PrepareTestEnv(t)
+
+ ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys")
+
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadRepo(t, ctx, 2)
+
+ addKeyForm := forms.AddKeyForm{
+ Title: "read-only",
+ Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n",
+ }
+ web.SetForm(ctx, &addKeyForm)
+ DeployKeysPost(ctx)
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
+ Name: addKeyForm.Title,
+ Content: addKeyForm.Content,
+ Mode: perm.AccessModeRead,
+ })
+}
+
+func TestAddReadWriteOnlyDeployKey(t *testing.T) {
+ if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil {
+ defer deferable()
+ } else {
+ return
+ }
+
+ unittest.PrepareTestEnv(t)
+
+ ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys")
+
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadRepo(t, ctx, 2)
+
+ addKeyForm := forms.AddKeyForm{
+ Title: "read-write",
+ Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n",
+ IsWritable: true,
+ }
+ web.SetForm(ctx, &addKeyForm)
+ DeployKeysPost(ctx)
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
+ Name: addKeyForm.Title,
+ Content: addKeyForm.Content,
+ Mode: perm.AccessModeWrite,
+ })
+}
+
+func TestCollaborationPost(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadUser(t, ctx, 4)
+ contexttest.LoadRepo(t, ctx, 1)
+
+ ctx.Req.Form.Set("collaborator", "user4")
+
+ u := &user_model.User{
+ ID: 2,
+ LowerName: "user2",
+ Type: user_model.UserTypeIndividual,
+ }
+
+ re := &repo_model.Repository{
+ ID: 2,
+ Owner: u,
+ OwnerID: u.ID,
+ }
+
+ repo := &context.Repository{
+ Owner: u,
+ Repository: re,
+ }
+
+ ctx.Repo = repo
+
+ CollaborationPost(ctx)
+
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+
+ exists, err := repo_model.IsCollaborator(ctx, re.ID, 4)
+ require.NoError(t, err)
+ assert.True(t, exists)
+}
+
+func TestCollaborationPost_InactiveUser(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadUser(t, ctx, 9)
+ contexttest.LoadRepo(t, ctx, 1)
+
+ ctx.Req.Form.Set("collaborator", "user9")
+
+ repo := &context.Repository{
+ Owner: &user_model.User{
+ LowerName: "user2",
+ },
+ }
+
+ ctx.Repo = repo
+
+ CollaborationPost(ctx)
+
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ assert.NotEmpty(t, ctx.Flash.ErrorMsg)
+}
+
+func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadUser(t, ctx, 4)
+ contexttest.LoadRepo(t, ctx, 1)
+
+ ctx.Req.Form.Set("collaborator", "user4")
+
+ u := &user_model.User{
+ ID: 2,
+ LowerName: "user2",
+ Type: user_model.UserTypeIndividual,
+ }
+
+ re := &repo_model.Repository{
+ ID: 2,
+ Owner: u,
+ OwnerID: u.ID,
+ }
+
+ repo := &context.Repository{
+ Owner: u,
+ Repository: re,
+ }
+
+ ctx.Repo = repo
+
+ CollaborationPost(ctx)
+
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+
+ exists, err := repo_model.IsCollaborator(ctx, re.ID, 4)
+ require.NoError(t, err)
+ assert.True(t, exists)
+
+ // Try adding the same collaborator again
+ CollaborationPost(ctx)
+
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ assert.NotEmpty(t, ctx.Flash.ErrorMsg)
+}
+
+func TestCollaborationPost_NonExistentUser(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.LoadRepo(t, ctx, 1)
+
+ ctx.Req.Form.Set("collaborator", "user34")
+
+ repo := &context.Repository{
+ Owner: &user_model.User{
+ LowerName: "user2",
+ },
+ }
+
+ ctx.Repo = repo
+
+ CollaborationPost(ctx)
+
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ assert.NotEmpty(t, ctx.Flash.ErrorMsg)
+}
+
+func TestAddTeamPost(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "org26/repo43")
+
+ ctx.Req.Form.Set("team", "team11")
+
+ org := &user_model.User{
+ LowerName: "org26",
+ Type: user_model.UserTypeOrganization,
+ }
+
+ team := &organization.Team{
+ ID: 11,
+ OrgID: 26,
+ }
+
+ re := &repo_model.Repository{
+ ID: 43,
+ Owner: org,
+ OwnerID: 26,
+ }
+
+ repo := &context.Repository{
+ Owner: &user_model.User{
+ ID: 26,
+ LowerName: "org26",
+ RepoAdminChangeTeamAccess: true,
+ },
+ Repository: re,
+ }
+
+ ctx.Repo = repo
+
+ AddTeamPost(ctx)
+
+ assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID))
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ assert.Empty(t, ctx.Flash.ErrorMsg)
+}
+
+func TestAddTeamPost_NotAllowed(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "org26/repo43")
+
+ ctx.Req.Form.Set("team", "team11")
+
+ org := &user_model.User{
+ LowerName: "org26",
+ Type: user_model.UserTypeOrganization,
+ }
+
+ team := &organization.Team{
+ ID: 11,
+ OrgID: 26,
+ }
+
+ re := &repo_model.Repository{
+ ID: 43,
+ Owner: org,
+ OwnerID: 26,
+ }
+
+ repo := &context.Repository{
+ Owner: &user_model.User{
+ ID: 26,
+ LowerName: "org26",
+ RepoAdminChangeTeamAccess: false,
+ },
+ Repository: re,
+ }
+
+ ctx.Repo = repo
+
+ AddTeamPost(ctx)
+
+ assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID))
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ assert.NotEmpty(t, ctx.Flash.ErrorMsg)
+}
+
+func TestAddTeamPost_AddTeamTwice(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "org26/repo43")
+
+ ctx.Req.Form.Set("team", "team11")
+
+ org := &user_model.User{
+ LowerName: "org26",
+ Type: user_model.UserTypeOrganization,
+ }
+
+ team := &organization.Team{
+ ID: 11,
+ OrgID: 26,
+ }
+
+ re := &repo_model.Repository{
+ ID: 43,
+ Owner: org,
+ OwnerID: 26,
+ }
+
+ repo := &context.Repository{
+ Owner: &user_model.User{
+ ID: 26,
+ LowerName: "org26",
+ RepoAdminChangeTeamAccess: true,
+ },
+ Repository: re,
+ }
+
+ ctx.Repo = repo
+
+ AddTeamPost(ctx)
+
+ AddTeamPost(ctx)
+ assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID))
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ assert.NotEmpty(t, ctx.Flash.ErrorMsg)
+}
+
+func TestAddTeamPost_NonExistentTeam(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "org26/repo43")
+
+ ctx.Req.Form.Set("team", "team-non-existent")
+
+ org := &user_model.User{
+ LowerName: "org26",
+ Type: user_model.UserTypeOrganization,
+ }
+
+ re := &repo_model.Repository{
+ ID: 43,
+ Owner: org,
+ OwnerID: 26,
+ }
+
+ repo := &context.Repository{
+ Owner: &user_model.User{
+ ID: 26,
+ LowerName: "org26",
+ RepoAdminChangeTeamAccess: true,
+ },
+ Repository: re,
+ }
+
+ ctx.Repo = repo
+
+ AddTeamPost(ctx)
+ assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
+ assert.NotEmpty(t, ctx.Flash.ErrorMsg)
+}
+
+func TestDeleteTeam(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ ctx, _ := contexttest.MockContext(t, "org3/team1/repo3")
+
+ ctx.Req.Form.Set("id", "2")
+
+ org := &user_model.User{
+ LowerName: "org3",
+ Type: user_model.UserTypeOrganization,
+ }
+
+ team := &organization.Team{
+ ID: 2,
+ OrgID: 3,
+ }
+
+ re := &repo_model.Repository{
+ ID: 3,
+ Owner: org,
+ OwnerID: 3,
+ }
+
+ repo := &context.Repository{
+ Owner: &user_model.User{
+ ID: 3,
+ LowerName: "org3",
+ RepoAdminChangeTeamAccess: true,
+ },
+ Repository: re,
+ }
+
+ ctx.Repo = repo
+
+ DeleteTeam(ctx)
+
+ assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID))
+}
diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go
new file mode 100644
index 0000000..45b6c0f
--- /dev/null
+++ b/routers/web/repo/setting/variables.go
@@ -0,0 +1,140 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ shared "code.gitea.io/gitea/routers/web/shared/actions"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplRepoVariables base.TplName = "repo/settings/actions"
+ tplOrgVariables base.TplName = "org/settings/actions"
+ tplUserVariables base.TplName = "user/settings/actions"
+ tplAdminVariables base.TplName = "admin/actions"
+)
+
+type variablesCtx struct {
+ OwnerID int64
+ RepoID int64
+ IsRepo bool
+ IsOrg bool
+ IsUser bool
+ IsGlobal bool
+ VariablesTemplate base.TplName
+ RedirectLink string
+}
+
+func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) {
+ if ctx.Data["PageIsRepoSettings"] == true {
+ return &variablesCtx{
+ OwnerID: 0,
+ RepoID: ctx.Repo.Repository.ID,
+ IsRepo: true,
+ VariablesTemplate: tplRepoVariables,
+ RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables",
+ }, nil
+ }
+
+ if ctx.Data["PageIsOrgSettings"] == true {
+ err := shared_user.LoadHeaderCount(ctx)
+ if err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return nil, nil
+ }
+ return &variablesCtx{
+ OwnerID: ctx.ContextUser.ID,
+ RepoID: 0,
+ IsOrg: true,
+ VariablesTemplate: tplOrgVariables,
+ RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables",
+ }, nil
+ }
+
+ if ctx.Data["PageIsUserSettings"] == true {
+ return &variablesCtx{
+ OwnerID: ctx.Doer.ID,
+ RepoID: 0,
+ IsUser: true,
+ VariablesTemplate: tplUserVariables,
+ RedirectLink: setting.AppSubURL + "/user/settings/actions/variables",
+ }, nil
+ }
+
+ if ctx.Data["PageIsAdmin"] == true {
+ return &variablesCtx{
+ OwnerID: 0,
+ RepoID: 0,
+ IsGlobal: true,
+ VariablesTemplate: tplAdminVariables,
+ RedirectLink: setting.AppSubURL + "/admin/actions/variables",
+ }, nil
+ }
+
+ return nil, errors.New("unable to set Variables context")
+}
+
+func Variables(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("actions.variables")
+ ctx.Data["PageType"] = "variables"
+ ctx.Data["PageIsSharedSettingsVariables"] = true
+
+ vCtx, err := getVariablesCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getVariablesCtx", err)
+ return
+ }
+
+ shared.SetVariablesContext(ctx, vCtx.OwnerID, vCtx.RepoID)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.HTML(http.StatusOK, vCtx.VariablesTemplate)
+}
+
+func VariableCreate(ctx *context.Context) {
+ vCtx, err := getVariablesCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getVariablesCtx", err)
+ return
+ }
+
+ if ctx.HasError() { // form binding validation error
+ ctx.JSONError(ctx.GetErrMsg())
+ return
+ }
+
+ shared.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, vCtx.RedirectLink)
+}
+
+func VariableUpdate(ctx *context.Context) {
+ vCtx, err := getVariablesCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getVariablesCtx", err)
+ return
+ }
+
+ if ctx.HasError() { // form binding validation error
+ ctx.JSONError(ctx.GetErrMsg())
+ return
+ }
+
+ shared.UpdateVariable(ctx, vCtx.RedirectLink)
+}
+
+func VariableDelete(ctx *context.Context) {
+ vCtx, err := getVariablesCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getVariablesCtx", err)
+ return
+ }
+ shared.DeleteVariable(ctx, vCtx.RedirectLink)
+}
diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go
new file mode 100644
index 0000000..eee493e
--- /dev/null
+++ b/routers/web/repo/setting/webhook.go
@@ -0,0 +1,485 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/web/middleware"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/services/forms"
+ webhook_service "code.gitea.io/gitea/services/webhook"
+
+ "gitea.com/go-chi/binding"
+)
+
+const (
+ tplHooks base.TplName = "repo/settings/webhook/base"
+ tplHookNew base.TplName = "repo/settings/webhook/new"
+ tplOrgHookNew base.TplName = "org/settings/hook_new"
+ tplUserHookNew base.TplName = "user/settings/hook_new"
+ tplAdminHookNew base.TplName = "admin/hook_new"
+)
+
+// WebhookList render web hooks list page
+func WebhookList(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.hooks")
+ ctx.Data["PageIsSettingsHooks"] = true
+ ctx.Data["BaseLink"] = ctx.Repo.RepoLink + "/settings/hooks"
+ ctx.Data["BaseLinkNew"] = ctx.Repo.RepoLink + "/settings/hooks"
+ ctx.Data["WebhookList"] = webhook_service.List()
+ ctx.Data["Description"] = ctx.Tr("repo.settings.hooks_desc", "https://forgejo.org/docs/latest/user/webhooks/")
+
+ ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{RepoID: ctx.Repo.Repository.ID})
+ if err != nil {
+ ctx.ServerError("GetWebhooksByRepoID", err)
+ return
+ }
+ ctx.Data["Webhooks"] = ws
+
+ ctx.HTML(http.StatusOK, tplHooks)
+}
+
+type ownerRepoCtx struct {
+ OwnerID int64
+ RepoID int64
+ IsAdmin bool
+ IsSystemWebhook bool
+ Link string
+ LinkNew string
+ NewTemplate base.TplName
+}
+
+// getOwnerRepoCtx determines whether this is a repo, owner, or admin (both default and system) context.
+func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) {
+ if ctx.Data["PageIsRepoSettings"] == true {
+ return &ownerRepoCtx{
+ RepoID: ctx.Repo.Repository.ID,
+ Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"),
+ LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"),
+ NewTemplate: tplHookNew,
+ }, nil
+ }
+
+ if ctx.Data["PageIsOrgSettings"] == true {
+ return &ownerRepoCtx{
+ OwnerID: ctx.ContextUser.ID,
+ Link: path.Join(ctx.Org.OrgLink, "settings/hooks"),
+ LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"),
+ NewTemplate: tplOrgHookNew,
+ }, nil
+ }
+
+ if ctx.Data["PageIsUserSettings"] == true {
+ return &ownerRepoCtx{
+ OwnerID: ctx.Doer.ID,
+ Link: path.Join(setting.AppSubURL, "/user/settings/hooks"),
+ LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"),
+ NewTemplate: tplUserHookNew,
+ }, nil
+ }
+
+ if ctx.Data["PageIsAdmin"] == true {
+ return &ownerRepoCtx{
+ IsAdmin: true,
+ IsSystemWebhook: ctx.Params(":configType") == "system-hooks",
+ Link: path.Join(setting.AppSubURL, "/admin/hooks"),
+ LinkNew: path.Join(setting.AppSubURL, "/admin/", ctx.Params(":configType")),
+ NewTemplate: tplAdminHookNew,
+ }, nil
+ }
+
+ return nil, errors.New("unable to set OwnerRepo context")
+}
+
+// WebhookNew render creating webhook page
+func WebhookNew(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook")
+ ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}}
+
+ orCtx, err := getOwnerRepoCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getOwnerRepoCtx", err)
+ return
+ }
+
+ if orCtx.IsAdmin && orCtx.IsSystemWebhook {
+ ctx.Data["PageIsAdminSystemHooks"] = true
+ ctx.Data["PageIsAdminSystemHooksNew"] = true
+ } else if orCtx.IsAdmin {
+ ctx.Data["PageIsAdminDefaultHooks"] = true
+ ctx.Data["PageIsAdminDefaultHooksNew"] = true
+ } else {
+ ctx.Data["PageIsSettingsHooks"] = true
+ ctx.Data["PageIsSettingsHooksNew"] = true
+ }
+
+ hookType := ctx.Params(":type")
+ handler := webhook_service.GetWebhookHandler(hookType)
+ if handler == nil {
+ ctx.NotFound("GetWebhookHandler", nil)
+ return
+ }
+ ctx.Data["HookType"] = hookType
+ ctx.Data["WebhookHandler"] = handler
+ ctx.Data["BaseLink"] = orCtx.LinkNew
+ ctx.Data["BaseLinkNew"] = orCtx.LinkNew
+ ctx.Data["WebhookList"] = webhook_service.List()
+
+ ctx.HTML(http.StatusOK, orCtx.NewTemplate)
+}
+
+// ParseHookEvent convert web form content to webhook.HookEvent
+func ParseHookEvent(form forms.WebhookCoreForm) *webhook_module.HookEvent {
+ return &webhook_module.HookEvent{
+ PushOnly: form.PushOnly(),
+ SendEverything: form.SendEverything(),
+ ChooseEvents: form.ChooseEvents(),
+ HookEvents: webhook_module.HookEvents{
+ Create: form.Create,
+ Delete: form.Delete,
+ Fork: form.Fork,
+ Issues: form.Issues,
+ IssueAssign: form.IssueAssign,
+ IssueLabel: form.IssueLabel,
+ IssueMilestone: form.IssueMilestone,
+ IssueComment: form.IssueComment,
+ Release: form.Release,
+ Push: form.Push,
+ PullRequest: form.PullRequest,
+ PullRequestAssign: form.PullRequestAssign,
+ PullRequestLabel: form.PullRequestLabel,
+ PullRequestMilestone: form.PullRequestMilestone,
+ PullRequestComment: form.PullRequestComment,
+ PullRequestReview: form.PullRequestReview,
+ PullRequestSync: form.PullRequestSync,
+ PullRequestReviewRequest: form.PullRequestReviewRequest,
+ Wiki: form.Wiki,
+ Repository: form.Repository,
+ Package: form.Package,
+ },
+ BranchFilter: form.BranchFilter,
+ }
+}
+
+func WebhookCreate(ctx *context.Context) {
+ hookType := ctx.Params(":type")
+ handler := webhook_service.GetWebhookHandler(hookType)
+ if handler == nil {
+ ctx.NotFound("GetWebhookHandler", nil)
+ return
+ }
+
+ fields := handler.UnmarshalForm(func(form any) {
+ errs := binding.Bind(ctx.Req, form)
+ middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError
+ })
+
+ ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook")
+ ctx.Data["PageIsSettingsHooks"] = true
+ ctx.Data["PageIsSettingsHooksNew"] = true
+ ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}}
+ ctx.Data["HookType"] = hookType
+ ctx.Data["WebhookHandler"] = handler
+
+ orCtx, err := getOwnerRepoCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getOwnerRepoCtx", err)
+ return
+ }
+ ctx.Data["BaseLink"] = orCtx.LinkNew
+ ctx.Data["BaseLinkNew"] = orCtx.LinkNew
+ ctx.Data["WebhookList"] = webhook_service.List()
+
+ if ctx.HasError() {
+ // pre-fill the form with the submitted data
+ var w webhook.Webhook
+ w.URL = fields.URL
+ w.ContentType = fields.ContentType
+ w.Secret = fields.Secret
+ w.HookEvent = ParseHookEvent(fields.WebhookCoreForm)
+ w.IsActive = fields.Active
+ w.HTTPMethod = fields.HTTPMethod
+ err := w.SetHeaderAuthorization(fields.AuthorizationHeader)
+ if err != nil {
+ ctx.ServerError("SetHeaderAuthorization", err)
+ return
+ }
+ ctx.Data["Webhook"] = w
+ ctx.Data["HookMetadata"] = fields.Metadata
+
+ ctx.HTML(http.StatusUnprocessableEntity, orCtx.NewTemplate)
+ return
+ }
+
+ var meta []byte
+ if fields.Metadata != nil {
+ meta, err = json.Marshal(fields.Metadata)
+ if err != nil {
+ ctx.ServerError("Marshal", err)
+ return
+ }
+ }
+
+ w := &webhook.Webhook{
+ RepoID: orCtx.RepoID,
+ URL: fields.URL,
+ HTTPMethod: fields.HTTPMethod,
+ ContentType: fields.ContentType,
+ Secret: fields.Secret,
+ HookEvent: ParseHookEvent(fields.WebhookCoreForm),
+ IsActive: fields.Active,
+ Type: hookType,
+ Meta: string(meta),
+ OwnerID: orCtx.OwnerID,
+ IsSystemWebhook: orCtx.IsSystemWebhook,
+ }
+ err = w.SetHeaderAuthorization(fields.AuthorizationHeader)
+ if err != nil {
+ ctx.ServerError("SetHeaderAuthorization", err)
+ return
+ }
+ if err := w.UpdateEvent(); err != nil {
+ ctx.ServerError("UpdateEvent", err)
+ return
+ } else if err := webhook.CreateWebhook(ctx, w); err != nil {
+ ctx.ServerError("CreateWebhook", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success"))
+ ctx.Redirect(orCtx.Link)
+}
+
+func WebhookUpdate(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook")
+ ctx.Data["PageIsSettingsHooks"] = true
+ ctx.Data["PageIsSettingsHooksEdit"] = true
+
+ orCtx, w := checkWebhook(ctx)
+ if ctx.Written() {
+ return
+ }
+ ctx.Data["Webhook"] = w
+
+ handler := webhook_service.GetWebhookHandler(w.Type)
+ if handler == nil {
+ ctx.NotFound("GetWebhookHandler", nil)
+ return
+ }
+
+ fields := handler.UnmarshalForm(func(form any) {
+ errs := binding.Bind(ctx.Req, form)
+ middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error checked below in ctx.HasError
+ })
+
+ // pre-fill the form with the submitted data
+ w.URL = fields.URL
+ w.ContentType = fields.ContentType
+ w.Secret = fields.Secret
+ w.HookEvent = ParseHookEvent(fields.WebhookCoreForm)
+ w.IsActive = fields.Active
+ w.HTTPMethod = fields.HTTPMethod
+
+ err := w.SetHeaderAuthorization(fields.AuthorizationHeader)
+ if err != nil {
+ ctx.ServerError("SetHeaderAuthorization", err)
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.Data["HookMetadata"] = fields.Metadata
+ ctx.HTML(http.StatusUnprocessableEntity, orCtx.NewTemplate)
+ return
+ }
+
+ var meta []byte
+ if fields.Metadata != nil {
+ meta, err = json.Marshal(fields.Metadata)
+ if err != nil {
+ ctx.ServerError("Marshal", err)
+ return
+ }
+ }
+
+ w.Meta = string(meta)
+
+ if err := w.UpdateEvent(); err != nil {
+ ctx.ServerError("UpdateEvent", err)
+ return
+ } else if err := webhook.UpdateWebhook(ctx, w); err != nil {
+ ctx.ServerError("UpdateWebhook", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success"))
+ ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID))
+}
+
+func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) {
+ orCtx, err := getOwnerRepoCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getOwnerRepoCtx", err)
+ return nil, nil
+ }
+ ctx.Data["BaseLink"] = orCtx.Link
+ ctx.Data["BaseLinkNew"] = orCtx.LinkNew
+ ctx.Data["WebhookList"] = webhook_service.List()
+
+ var w *webhook.Webhook
+ if orCtx.RepoID > 0 {
+ w, err = webhook.GetWebhookByRepoID(ctx, orCtx.RepoID, ctx.ParamsInt64(":id"))
+ } else if orCtx.OwnerID > 0 {
+ w, err = webhook.GetWebhookByOwnerID(ctx, orCtx.OwnerID, ctx.ParamsInt64(":id"))
+ } else if orCtx.IsAdmin {
+ w, err = webhook.GetSystemOrDefaultWebhook(ctx, ctx.ParamsInt64(":id"))
+ }
+ if err != nil || w == nil {
+ if webhook.IsErrWebhookNotExist(err) {
+ ctx.NotFound("GetWebhookByID", nil)
+ } else {
+ ctx.ServerError("GetWebhookByID", err)
+ }
+ return nil, nil
+ }
+
+ ctx.Data["HookType"] = w.Type
+
+ if handler := webhook_service.GetWebhookHandler(w.Type); handler != nil {
+ ctx.Data["HookMetadata"] = handler.Metadata(w)
+ ctx.Data["WebhookHandler"] = handler
+ }
+
+ ctx.Data["History"], err = w.History(ctx, 1)
+ if err != nil {
+ ctx.ServerError("History", err)
+ }
+ return orCtx, w
+}
+
+// WebhookEdit render editing web hook page
+func WebhookEdit(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook")
+ ctx.Data["PageIsSettingsHooks"] = true
+ ctx.Data["PageIsSettingsHooksEdit"] = true
+
+ orCtx, w := checkWebhook(ctx)
+ if ctx.Written() {
+ return
+ }
+ ctx.Data["Webhook"] = w
+
+ ctx.HTML(http.StatusOK, orCtx.NewTemplate)
+}
+
+// WebhookTest test if web hook is work fine
+func WebhookTest(ctx *context.Context) {
+ hookID := ctx.ParamsInt64(":id")
+ w, err := webhook.GetWebhookByRepoID(ctx, ctx.Repo.Repository.ID, hookID)
+ if err != nil {
+ ctx.Flash.Error("GetWebhookByRepoID: " + err.Error())
+ ctx.Status(http.StatusInternalServerError)
+ return
+ }
+
+ // Grab latest commit or fake one if it's empty repository.
+ commit := ctx.Repo.Commit
+ if commit == nil {
+ ghost := user_model.NewGhostUser()
+ objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
+ commit = &git.Commit{
+ ID: objectFormat.EmptyObjectID(),
+ Author: ghost.NewGitSig(),
+ Committer: ghost.NewGitSig(),
+ CommitMessage: "This is a fake commit",
+ }
+ }
+
+ apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone)
+
+ apiCommit := &api.PayloadCommit{
+ ID: commit.ID.String(),
+ Message: commit.Message(),
+ URL: ctx.Repo.Repository.HTMLURL() + "/commit/" + url.PathEscape(commit.ID.String()),
+ Author: &api.PayloadUser{
+ Name: commit.Author.Name,
+ Email: commit.Author.Email,
+ },
+ Committer: &api.PayloadUser{
+ Name: commit.Committer.Name,
+ Email: commit.Committer.Email,
+ },
+ }
+
+ commitID := commit.ID.String()
+ p := &api.PushPayload{
+ Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch,
+ Before: commitID,
+ After: commitID,
+ CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID),
+ Commits: []*api.PayloadCommit{apiCommit},
+ TotalCommits: 1,
+ HeadCommit: apiCommit,
+ Repo: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}),
+ Pusher: apiUser,
+ Sender: apiUser,
+ }
+ if err := webhook_service.PrepareWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
+ ctx.Flash.Error("PrepareWebhook: " + err.Error())
+ ctx.Status(http.StatusInternalServerError)
+ } else {
+ ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success"))
+ ctx.Status(http.StatusOK)
+ }
+}
+
+// WebhookReplay replays a webhook
+func WebhookReplay(ctx *context.Context) {
+ hookTaskUUID := ctx.Params(":uuid")
+
+ orCtx, w := checkWebhook(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if err := webhook_service.ReplayHookTask(ctx, w, hookTaskUUID); err != nil {
+ if webhook.IsErrHookTaskNotExist(err) {
+ ctx.NotFound("ReplayHookTask", nil)
+ } else {
+ ctx.ServerError("ReplayHookTask", err)
+ }
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.webhook.delivery.success"))
+ ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID))
+}
+
+// WebhookDelete delete a webhook
+func WebhookDelete(ctx *context.Context) {
+ if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
+ }
+
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/hooks")
+}