summaryrefslogtreecommitdiffstats
path: root/routers/private
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--routers/private/actions.go97
-rw-r--r--routers/private/default_branch.go40
-rw-r--r--routers/private/hook_post_receive.go368
-rw-r--r--routers/private/hook_post_receive_test.go50
-rw-r--r--routers/private/hook_pre_receive.go629
-rw-r--r--routers/private/hook_proc_receive.go43
-rw-r--r--routers/private/hook_verification.go122
-rw-r--r--routers/private/hook_verification_test.go46
-rw-r--r--routers/private/internal.go84
-rw-r--r--routers/private/internal_repo.go69
-rw-r--r--routers/private/key.go61
-rw-r--r--routers/private/mail.go91
-rw-r--r--routers/private/main_test.go14
-rw-r--r--routers/private/manager.go195
-rw-r--r--routers/private/manager_process.go160
-rw-r--r--routers/private/manager_unix.go25
-rw-r--r--routers/private/manager_windows.go27
-rw-r--r--routers/private/restore_repo.go53
-rw-r--r--routers/private/serv.go397
-rw-r--r--routers/private/ssh_log.go33
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/HEAD1
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/config6
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/info/refs1
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/logs/HEAD1
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main1
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2cbin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2abin0 -> 22 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1ebin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86bin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7bin0 -> 55 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7bin0 -> 50 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c2
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababdbin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfebin0 -> 684 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a2
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498bbin0 -> 153 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141bin0 -> 24 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9bin0 -> 63 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18cbin0 -> 682 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa3
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be323
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451bin0 -> 59 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391bin0 -> 15 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759ebbin0 -> 677 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/info/packs1
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2cbin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2abin0 -> 22 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1ebin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86bin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababdbin0 -> 86 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfebin0 -> 684 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498bbin0 -> 153 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141bin0 -> 24 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18cbin0 -> 682 bytes
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa3
-rw-r--r--routers/private/tests/repos/repo1_hook_verification/refs/heads/main1
-rw-r--r--routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt127
57 files changed, 2756 insertions, 0 deletions
diff --git a/routers/private/actions.go b/routers/private/actions.go
new file mode 100644
index 0000000..425c480
--- /dev/null
+++ b/routers/private/actions.go
@@ -0,0 +1,97 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ gocontext "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/context"
+)
+
+// GenerateActionsRunnerToken generates a new runner token for a given scope
+func GenerateActionsRunnerToken(ctx *context.PrivateContext) {
+ var genRequest private.GenerateTokenRequest
+ rd := ctx.Req.Body
+ defer rd.Close()
+
+ if err := json.NewDecoder(rd).Decode(&genRequest); err != nil {
+ log.Error("JSON Decode failed: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+
+ owner, repo, err := parseScope(ctx, genRequest.Scope)
+ if err != nil {
+ log.Error("parseScope failed: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ }
+
+ token, err := actions_model.GetLatestRunnerToken(ctx, owner, repo)
+ if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) {
+ token, err = actions_model.NewRunnerToken(ctx, owner, repo)
+ if err != nil {
+ errMsg := fmt.Sprintf("error while creating runner token: %v", err)
+ log.Error("NewRunnerToken failed: %v", errMsg)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: errMsg,
+ })
+ return
+ }
+ } else if err != nil {
+ errMsg := fmt.Sprintf("could not get unactivated runner token: %v", err)
+ log.Error("GetLatestRunnerToken failed: %v", errMsg)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: errMsg,
+ })
+ return
+ }
+
+ ctx.PlainText(http.StatusOK, token.Token)
+}
+
+func ParseScope(ctx gocontext.Context, scope string) (ownerID, repoID int64, err error) {
+ return parseScope(ctx, scope)
+}
+
+func parseScope(ctx gocontext.Context, scope string) (ownerID, repoID int64, err error) {
+ ownerID = 0
+ repoID = 0
+ if scope == "" {
+ return ownerID, repoID, nil
+ }
+
+ ownerName, repoName, found := strings.Cut(scope, "/")
+
+ u, err := user_model.GetUserByName(ctx, ownerName)
+ if err != nil {
+ return ownerID, repoID, err
+ }
+ ownerID = u.ID
+
+ if !found {
+ return ownerID, repoID, nil
+ }
+
+ r, err := repo_model.GetRepositoryByName(ctx, u.ID, repoName)
+ if err != nil {
+ return ownerID, repoID, err
+ }
+ repoID = r.ID
+ return ownerID, repoID, nil
+}
diff --git a/routers/private/default_branch.go b/routers/private/default_branch.go
new file mode 100644
index 0000000..33890be
--- /dev/null
+++ b/routers/private/default_branch.go
@@ -0,0 +1,40 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "fmt"
+ "net/http"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/private"
+ gitea_context "code.gitea.io/gitea/services/context"
+)
+
+// SetDefaultBranch updates the default branch
+func SetDefaultBranch(ctx *gitea_context.PrivateContext) {
+ ownerName := ctx.Params(":owner")
+ repoName := ctx.Params(":repo")
+ branch := ctx.Params(":branch")
+
+ ctx.Repo.Repository.DefaultBranch = branch
+ if err := gitrepo.SetDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch); err != nil {
+ if !git.IsErrUnsupportedVersion(err) {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+ }
+
+ if err := repo_model.UpdateDefaultBranch(ctx, ctx.Repo.Repository); err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+ ctx.PlainText(http.StatusOK, "success")
+}
diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go
new file mode 100644
index 0000000..11d1161
--- /dev/null
+++ b/routers/private/hook_post_receive.go
@@ -0,0 +1,368 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ pull_model "code.gitea.io/gitea/models/pull"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/git/pushoptions"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ timeutil "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ gitea_context "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// HookPostReceive updates services and users
+func HookPostReceive(ctx *gitea_context.PrivateContext) {
+ opts := web.GetForm(ctx).(*private.HookOptions)
+
+ // We don't rely on RepoAssignment here because:
+ // a) we don't need the git repo in this function
+ // OUT OF DATE: we do need the git repo to sync the branch to the db now.
+ // b) our update function will likely change the repository in the db so we will need to refresh it
+ // c) we don't always need the repo
+
+ ownerName := ctx.Params(":owner")
+ repoName := ctx.Params(":repo")
+
+ // defer getting the repository at this point - as we should only retrieve it if we're going to call update
+ var (
+ repo *repo_model.Repository
+ gitRepo *git.Repository
+ )
+ defer gitRepo.Close() // it's safe to call Close on a nil pointer
+
+ updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs))
+ wasEmpty := false
+
+ for i := range opts.OldCommitIDs {
+ refFullName := opts.RefFullNames[i]
+
+ // Only trigger activity updates for changes to branches or
+ // tags. Updates to other refs (eg, refs/notes, refs/changes,
+ // or other less-standard refs spaces are ignored since there
+ // may be a very large number of them).
+ if refFullName.IsBranch() || refFullName.IsTag() {
+ if repo == nil {
+ repo = loadRepository(ctx, ownerName, repoName)
+ if ctx.Written() {
+ // Error handled in loadRepository
+ return
+ }
+ wasEmpty = repo.IsEmpty
+ }
+
+ option := &repo_module.PushUpdateOptions{
+ RefFullName: refFullName,
+ OldCommitID: opts.OldCommitIDs[i],
+ NewCommitID: opts.NewCommitIDs[i],
+ PusherID: opts.UserID,
+ PusherName: opts.UserName,
+ RepoUserName: ownerName,
+ RepoName: repoName,
+ TimeNano: time.Now().UnixNano(),
+ }
+ updates = append(updates, option)
+ if repo.IsEmpty && (refFullName.BranchName() == "master" || refFullName.BranchName() == "main") {
+ // put the master/main branch first
+ // FIXME: It doesn't always work, since the master/main branch may not be the first batch of updates.
+ // If the user pushes many branches at once, the Git hook will call the internal API in batches, rather than all at once.
+ // See https://github.com/go-gitea/gitea/blob/cb52b17f92e2d2293f7c003649743464492bca48/cmd/hook.go#L27
+ // If the user executes `git push origin --all` and pushes more than 30 branches, the master/main may not be the default branch.
+ copy(updates[1:], updates)
+ updates[0] = option
+ }
+ }
+ }
+
+ if repo != nil && len(updates) > 0 {
+ branchesToSync := make([]*repo_module.PushUpdateOptions, 0, len(updates))
+ for _, update := range updates {
+ if !update.RefFullName.IsBranch() {
+ continue
+ }
+ if repo == nil {
+ repo = loadRepository(ctx, ownerName, repoName)
+ if ctx.Written() {
+ return
+ }
+ wasEmpty = repo.IsEmpty
+ }
+
+ if update.IsDelRef() {
+ if err := git_model.AddDeletedBranch(ctx, repo.ID, update.RefFullName.BranchName(), update.PusherID); err != nil {
+ log.Error("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+ } else {
+ branchesToSync = append(branchesToSync, update)
+ }
+ }
+ if len(branchesToSync) > 0 {
+ var err error
+ gitRepo, err = gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+
+ var (
+ branchNames = make([]string, 0, len(branchesToSync))
+ commitIDs = make([]string, 0, len(branchesToSync))
+ )
+ for _, update := range branchesToSync {
+ branchNames = append(branchNames, update.RefFullName.BranchName())
+ commitIDs = append(commitIDs, update.NewCommitID)
+ }
+
+ if err := repo_service.SyncBranchesToDB(ctx, repo.ID, opts.UserID, branchNames, commitIDs, gitRepo.GetCommit); err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf("Failed to sync branch to DB in repository: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+ }
+
+ if err := repo_service.PushUpdates(updates); err != nil {
+ log.Error("Failed to Update: %s/%s Total Updates: %d", ownerName, repoName, len(updates))
+ for i, update := range updates {
+ log.Error("Failed to Update: %s/%s Update: %d/%d: Branch: %s", ownerName, repoName, i, len(updates), update.RefFullName.BranchName())
+ }
+ log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
+
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+ }
+
+ // handle pull request merging, a pull request action should push at least 1 commit
+ if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase {
+ handlePullRequestMerging(ctx, opts, ownerName, repoName, updates)
+ if ctx.Written() {
+ return
+ }
+ }
+
+ // Handle Push Options
+ if !opts.GetGitPushOptions().Empty() {
+ // load the repository
+ if repo == nil {
+ repo = loadRepository(ctx, ownerName, repoName)
+ if ctx.Written() {
+ // Error handled in loadRepository
+ return
+ }
+ wasEmpty = repo.IsEmpty
+ }
+
+ repo.IsPrivate = opts.GetGitPushOptions().GetBool(pushoptions.RepoPrivate, repo.IsPrivate)
+ repo.IsTemplate = opts.GetGitPushOptions().GetBool(pushoptions.RepoTemplate, repo.IsTemplate)
+ if err := repo_model.UpdateRepositoryCols(ctx, repo, "is_private", "is_template"); err != nil {
+ log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ }
+ }
+
+ results := make([]private.HookPostReceiveBranchResult, 0, len(opts.OldCommitIDs))
+
+ // We have to reload the repo in case its state is changed above
+ repo = nil
+ var baseRepo *repo_model.Repository
+
+ // Now handle the pull request notification trailers
+ for i := range opts.OldCommitIDs {
+ refFullName := opts.RefFullNames[i]
+ newCommitID := opts.NewCommitIDs[i]
+
+ // post update for agit pull request
+ // FIXME: use pr.Flow to test whether it's an Agit PR or a GH PR
+ if git.SupportProcReceive && refFullName.IsPull() {
+ if repo == nil {
+ repo = loadRepository(ctx, ownerName, repoName)
+ if ctx.Written() {
+ return
+ }
+ }
+
+ pullIndex, _ := strconv.ParseInt(refFullName.PullName(), 10, 64)
+ if pullIndex <= 0 {
+ continue
+ }
+
+ pr, err := issues_model.GetPullRequestByIndex(ctx, repo.ID, pullIndex)
+ if err != nil && !issues_model.IsErrPullRequestNotExist(err) {
+ log.Error("Failed to get PR by index %v Error: %v", pullIndex, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to get PR by index %v Error: %v", pullIndex, err),
+ })
+ return
+ }
+ if pr == nil {
+ continue
+ }
+
+ results = append(results, private.HookPostReceiveBranchResult{
+ Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx),
+ Create: false,
+ Branch: "",
+ URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index),
+ })
+ continue
+ }
+
+ // If we've pushed a branch (and not deleted it)
+ if !git.IsEmptyCommitID(newCommitID, nil) && refFullName.IsBranch() {
+ // First ensure we have the repository loaded, we're allowed pulls requests and we can get the base repo
+ if repo == nil {
+ repo = loadRepository(ctx, ownerName, repoName)
+ if ctx.Written() {
+ return
+ }
+
+ baseRepo = repo
+
+ if repo.IsFork {
+ if err := repo.GetBaseRepo(ctx); err != nil {
+ log.Error("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf("Failed to get Base Repository of Forked repository: %-v Error: %v", repo, err),
+ RepoWasEmpty: wasEmpty,
+ })
+ return
+ }
+ if repo.BaseRepo.AllowsPulls(ctx) {
+ baseRepo = repo.BaseRepo
+ }
+ }
+
+ if !baseRepo.AllowsPulls(ctx) {
+ // We can stop there's no need to go any further
+ ctx.JSON(http.StatusOK, private.HookPostReceiveResult{
+ RepoWasEmpty: wasEmpty,
+ })
+ return
+ }
+ }
+
+ branch := refFullName.BranchName()
+
+ // If our branch is the default branch of an unforked repo - there's no PR to create or refer to
+ if !repo.IsFork && branch == baseRepo.DefaultBranch {
+ results = append(results, private.HookPostReceiveBranchResult{})
+ continue
+ }
+
+ pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch, issues_model.PullRequestFlowGithub)
+ if err != nil && !issues_model.IsErrPullRequestNotExist(err) {
+ log.Error("Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf(
+ "Failed to get active PR in: %-v Branch: %s to: %-v Branch: %s Error: %v", repo, branch, baseRepo, baseRepo.DefaultBranch, err),
+ RepoWasEmpty: wasEmpty,
+ })
+ return
+ }
+
+ if pr == nil {
+ if repo.IsFork {
+ branch = fmt.Sprintf("%s:%s", repo.OwnerName, branch)
+ }
+ results = append(results, private.HookPostReceiveBranchResult{
+ Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(ctx),
+ Create: true,
+ Branch: branch,
+ URL: fmt.Sprintf("%s/compare/%s...%s", baseRepo.HTMLURL(), util.PathEscapeSegments(baseRepo.DefaultBranch), util.PathEscapeSegments(branch)),
+ })
+ } else {
+ results = append(results, private.HookPostReceiveBranchResult{
+ Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(ctx),
+ Create: false,
+ Branch: branch,
+ URL: fmt.Sprintf("%s/pulls/%d", baseRepo.HTMLURL(), pr.Index),
+ })
+ }
+ }
+ }
+ ctx.JSON(http.StatusOK, private.HookPostReceiveResult{
+ Results: results,
+ RepoWasEmpty: wasEmpty,
+ })
+}
+
+func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
+ return cache.GetWithContextCache(ctx, "hook_post_receive_user", id, func() (*user_model.User, error) {
+ return user_model.GetUserByID(ctx, id)
+ })
+}
+
+// handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
+func handlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, ownerName, repoName string, updates []*repo_module.PushUpdateOptions) {
+ if len(updates) == 0 {
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{
+ Err: fmt.Sprintf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID),
+ })
+ return
+ }
+
+ pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID)
+ if err != nil {
+ log.Error("GetPullRequestByID[%d]: %v", opts.PullRequestID, err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "GetPullRequestByID failed"})
+ return
+ }
+
+ pusher, err := loadContextCacheUser(ctx, opts.UserID)
+ if err != nil {
+ log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Load pusher user failed"})
+ return
+ }
+
+ pr.MergedCommitID = updates[len(updates)-1].NewCommitID
+ pr.MergedUnix = timeutil.TimeStampNow()
+ pr.Merger = pusher
+ pr.MergerID = pusher.ID
+ err = db.WithTx(ctx, func(ctx context.Context) error {
+ // Removing an auto merge pull and ignore if not exist
+ if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
+ return fmt.Errorf("DeleteScheduledAutoMerge[%d]: %v", opts.PullRequestID, err)
+ }
+ if _, err := pr.SetMerged(ctx); err != nil {
+ return fmt.Errorf("SetMerged failed: %s/%s Error: %v", ownerName, repoName, err)
+ }
+ return nil
+ })
+ if err != nil {
+ log.Error("Failed to update PR to merged: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to update PR to merged"})
+ }
+}
diff --git a/routers/private/hook_post_receive_test.go b/routers/private/hook_post_receive_test.go
new file mode 100644
index 0000000..bfd647e
--- /dev/null
+++ b/routers/private/hook_post_receive_test.go
@@ -0,0 +1,50 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ pull_model "code.gitea.io/gitea/models/pull"
+ 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/private"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/services/contexttest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHandlePullRequestMerging(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ pr, err := issues_model.GetUnmergedPullRequest(db.DefaultContext, 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub)
+ require.NoError(t, err)
+ require.NoError(t, pr.LoadBaseRepo(db.DefaultContext))
+
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr")
+ require.NoError(t, err)
+
+ autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
+
+ ctx, resp := contexttest.MockPrivateContext(t, "/")
+ handlePullRequestMerging(ctx, &private.HookOptions{
+ PullRequestID: pr.ID,
+ UserID: 2,
+ }, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, []*repo_module.PushUpdateOptions{
+ {NewCommitID: "01234567"},
+ })
+ assert.Empty(t, resp.Body.String())
+ pr, err = issues_model.GetPullRequestByID(db.DefaultContext, pr.ID)
+ require.NoError(t, err)
+ assert.True(t, pr.HasMerged)
+ assert.EqualValues(t, "01234567", pr.MergedCommitID)
+
+ unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{ID: autoMerge.ID})
+}
diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go
new file mode 100644
index 0000000..4b8439d
--- /dev/null
+++ b/routers/private/hook_pre_receive.go
@@ -0,0 +1,629 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+
+ "code.gitea.io/gitea/models"
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ perm_model "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ quota_model "code.gitea.io/gitea/models/quota"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ gitea_context "code.gitea.io/gitea/services/context"
+ pull_service "code.gitea.io/gitea/services/pull"
+)
+
+type preReceiveContext struct {
+ *gitea_context.PrivateContext
+
+ // loadedPusher indicates that where the following information are loaded
+ loadedPusher bool
+ user *user_model.User // it's the org user if a DeployKey is used
+ userPerm access_model.Permission
+ deployKeyAccessMode perm_model.AccessMode
+
+ canCreatePullRequest bool
+ checkedCanCreatePullRequest bool
+
+ canWriteCode bool
+ checkedCanWriteCode bool
+
+ protectedTags []*git_model.ProtectedTag
+ gotProtectedTags bool
+
+ env []string
+
+ opts *private.HookOptions
+
+ isOverQuota bool
+
+ branchName string
+}
+
+// CanWriteCode returns true if pusher can write code
+func (ctx *preReceiveContext) CanWriteCode() bool {
+ if !ctx.checkedCanWriteCode {
+ if !ctx.loadPusherAndPermission() {
+ return false
+ }
+ ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
+ ctx.checkedCanWriteCode = true
+ }
+ return ctx.canWriteCode
+}
+
+// AssertCanWriteCode returns true if pusher can write code
+func (ctx *preReceiveContext) AssertCanWriteCode() bool {
+ if !ctx.CanWriteCode() {
+ if ctx.Written() {
+ return false
+ }
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "User permission denied for writing.",
+ })
+ return false
+ }
+ return true
+}
+
+// CanCreatePullRequest returns true if pusher can create pull requests
+func (ctx *preReceiveContext) CanCreatePullRequest() bool {
+ if !ctx.checkedCanCreatePullRequest {
+ if !ctx.loadPusherAndPermission() {
+ return false
+ }
+ ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
+ ctx.checkedCanCreatePullRequest = true
+ }
+ return ctx.canCreatePullRequest
+}
+
+// AssertCreatePullRequest returns true if can create pull requests
+func (ctx *preReceiveContext) AssertCreatePullRequest() bool {
+ if !ctx.CanCreatePullRequest() {
+ if ctx.Written() {
+ return false
+ }
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "User permission denied for creating pull-request.",
+ })
+ return false
+ }
+ return true
+}
+
+var errPermissionDenied = errors.New("permission denied for changing repo settings")
+
+func (ctx *preReceiveContext) canChangeSettings() error {
+ if !ctx.loadPusherAndPermission() {
+ return errPermissionDenied
+ }
+
+ if !ctx.userPerm.IsOwner() && !ctx.userPerm.IsAdmin() {
+ return errPermissionDenied
+ }
+
+ if ctx.Repo.Repository.IsFork {
+ return errPermissionDenied
+ }
+
+ return nil
+}
+
+func (ctx *preReceiveContext) validatePushOptions() error {
+ opts := web.GetForm(ctx).(*private.HookOptions)
+
+ if opts.GetGitPushOptions().ChangeRepoSettings() {
+ return ctx.canChangeSettings()
+ }
+
+ return nil
+}
+
+func (ctx *preReceiveContext) assertPushOptions() bool {
+ if err := ctx.validatePushOptions(); err != nil {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("options validation failed: %v", err),
+ })
+ return false
+ }
+ return true
+}
+
+func (ctx *preReceiveContext) checkQuota() error {
+ if !setting.Quota.Enabled {
+ ctx.isOverQuota = false
+ return nil
+ }
+
+ if !ctx.loadPusherAndPermission() {
+ ctx.isOverQuota = true
+ return nil
+ }
+
+ ok, err := quota_model.EvaluateForUser(ctx, ctx.PrivateContext.Repo.Repository.OwnerID, quota_model.LimitSubjectSizeReposAll)
+ if err != nil {
+ log.Error("quota_model.EvaluateForUser: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ UserMsg: "Error checking user quota",
+ })
+ return err
+ }
+
+ ctx.isOverQuota = !ok
+ return nil
+}
+
+func (ctx *preReceiveContext) quotaExceeded() {
+ ctx.JSON(http.StatusRequestEntityTooLarge, private.Response{
+ UserMsg: "Quota exceeded",
+ })
+}
+
+// HookPreReceive checks whether a individual commit is acceptable
+func HookPreReceive(ctx *gitea_context.PrivateContext) {
+ opts := web.GetForm(ctx).(*private.HookOptions)
+
+ ourCtx := &preReceiveContext{
+ PrivateContext: ctx,
+ env: generateGitEnv(opts), // Generate git environment for checking commits
+ opts: opts,
+ }
+
+ if !ourCtx.assertPushOptions() {
+ log.Trace("Git push options validation failed")
+ return
+ }
+ log.Trace("Git push options validation succeeded")
+
+ if err := ourCtx.checkQuota(); err != nil {
+ return
+ }
+
+ // Iterate across the provided old commit IDs
+ for i := range opts.OldCommitIDs {
+ oldCommitID := opts.OldCommitIDs[i]
+ newCommitID := opts.NewCommitIDs[i]
+ refFullName := opts.RefFullNames[i]
+
+ switch {
+ case refFullName.IsBranch():
+ preReceiveBranch(ourCtx, oldCommitID, newCommitID, refFullName)
+ case refFullName.IsTag():
+ preReceiveTag(ourCtx, oldCommitID, newCommitID, refFullName)
+ case git.SupportProcReceive && refFullName.IsFor():
+ preReceiveFor(ourCtx, oldCommitID, newCommitID, refFullName)
+ default:
+ if ourCtx.isOverQuota {
+ ourCtx.quotaExceeded()
+ return
+ }
+ ourCtx.AssertCanWriteCode()
+ }
+ if ctx.Written() {
+ return
+ }
+ }
+
+ ctx.PlainText(http.StatusOK, "ok")
+}
+
+func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
+ branchName := refFullName.BranchName()
+ ctx.branchName = branchName
+
+ if !ctx.AssertCanWriteCode() {
+ return
+ }
+
+ repo := ctx.Repo.Repository
+ gitRepo := ctx.Repo.GitRepo
+ objectFormat := ctx.Repo.GetObjectFormat()
+
+ if branchName == repo.DefaultBranch && newCommitID == objectFormat.EmptyObjectID().String() {
+ log.Warn("Forbidden: Branch: %s is the default branch in %-v and cannot be deleted", branchName, repo)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("branch %s is the default branch and cannot be deleted", branchName),
+ })
+ return
+ }
+
+ protectBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
+ if err != nil {
+ log.Error("Unable to get protected branch: %s in %-v Error: %v", branchName, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+
+ // Allow pushes to non-protected branches
+ if protectBranch == nil {
+ // ...unless the user is over quota, and the operation is not a delete
+ if newCommitID != objectFormat.EmptyObjectID().String() && ctx.isOverQuota {
+ ctx.quotaExceeded()
+ }
+
+ return
+ }
+ protectBranch.Repo = repo
+
+ // This ref is a protected branch.
+ //
+ // First of all we need to enforce absolutely:
+ //
+ // 1. Detect and prevent deletion of the branch
+ if newCommitID == objectFormat.EmptyObjectID().String() {
+ log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName),
+ })
+ return
+ }
+
+ // 2. Disallow force pushes to protected branches
+ if oldCommitID != objectFormat.EmptyObjectID().String() {
+ output, _, err := git.NewCommand(ctx, "rev-list", "--max-count=1").AddDynamicArguments(oldCommitID, "^"+newCommitID).RunStdString(&git.RunOpts{Dir: repo.RepoPath(), Env: ctx.env})
+ if err != nil {
+ log.Error("Unable to detect force push between: %s and %s in %-v Error: %v", oldCommitID, newCommitID, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Fail to detect force push: %v", err),
+ })
+ return
+ } else if len(output) > 0 {
+ log.Warn("Forbidden: Branch: %s in %-v is protected from force push", branchName, repo)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("branch %s is protected from force push", branchName),
+ })
+ return
+ }
+ }
+
+ // 3. Enforce require signed commits
+ if protectBranch.RequireSignedCommits {
+ err := verifyCommits(oldCommitID, newCommitID, gitRepo, ctx.env)
+ if err != nil {
+ if !isErrUnverifiedCommit(err) {
+ log.Error("Unable to check commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to check commits from %s to %s: %v", oldCommitID, newCommitID, err),
+ })
+ return
+ }
+ unverifiedCommit := err.(*errUnverifiedCommit).sha
+ log.Warn("Forbidden: Branch: %s in %-v is protected from unverified commit %s", branchName, repo, unverifiedCommit)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("branch %s is protected from unverified commit %s", branchName, unverifiedCommit),
+ })
+ return
+ }
+ }
+
+ // Now there are several tests which can be overridden:
+ //
+ // 4. Check protected file patterns - this is overridable from the UI
+ changedProtectedfiles := false
+ protectedFilePath := ""
+
+ globs := protectBranch.GetProtectedFilePatterns()
+ if len(globs) > 0 {
+ _, err := pull_service.CheckFileProtection(gitRepo, oldCommitID, newCommitID, globs, 1, ctx.env)
+ if err != nil {
+ if !models.IsErrFilePathProtected(err) {
+ log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err),
+ })
+ return
+ }
+
+ changedProtectedfiles = true
+ protectedFilePath = err.(models.ErrFilePathProtected).Path
+ }
+ }
+
+ // 5. Check if the doer is allowed to push
+ var canPush bool
+ if ctx.opts.DeployKeyID != 0 {
+ canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
+ } else {
+ user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
+ if err != nil {
+ log.Error("Unable to GetUserByID for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to GetUserByID for commits from %s to %s: %v", oldCommitID, newCommitID, err),
+ })
+ return
+ }
+ canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, user)
+ }
+
+ // 6. If we're not allowed to push directly
+ if !canPush {
+ // Is this is a merge from the UI/API?
+ if ctx.opts.PullRequestID == 0 {
+ // 6a. If we're not merging from the UI/API then there are two ways we got here:
+ //
+ // We are changing a protected file and we're not allowed to do that
+ if changedProtectedfiles {
+ log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath),
+ })
+ return
+ }
+
+ // Allow commits that only touch unprotected files
+ globs := protectBranch.GetUnprotectedFilePatterns()
+ if len(globs) > 0 {
+ unprotectedFilesOnly, err := pull_service.CheckUnprotectedFiles(gitRepo, oldCommitID, newCommitID, globs, ctx.env)
+ if err != nil {
+ log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err),
+ })
+ return
+ }
+ if unprotectedFilesOnly {
+ // Commit only touches unprotected files, this is allowed
+ return
+ }
+ }
+
+ // Or we're simply not able to push to this protected branch
+ log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v", ctx.opts.UserID, branchName, repo)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s", branchName),
+ })
+ return
+ }
+ // 6b. Merge (from UI or API)
+
+ // Get the PR, user and permissions for the user in the repository
+ pr, err := issues_model.GetPullRequestByID(ctx, ctx.opts.PullRequestID)
+ if err != nil {
+ log.Error("Unable to get PullRequest %d Error: %v", ctx.opts.PullRequestID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get PullRequest %d Error: %v", ctx.opts.PullRequestID, err),
+ })
+ return
+ }
+
+ // although we should have called `loadPusherAndPermission` before, here we call it explicitly again because we need to access ctx.user below
+ if !ctx.loadPusherAndPermission() {
+ // if error occurs, loadPusherAndPermission had written the error response
+ return
+ }
+
+ // Now check if the user is allowed to merge PRs for this repository
+ // Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
+ allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
+ if err != nil {
+ log.Error("Error calculating if allowed to merge: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Error calculating if allowed to merge: %v", err),
+ })
+ return
+ }
+
+ if !allowedMerge {
+ log.Warn("Forbidden: User %d is not allowed to push to protected branch: %s in %-v and is not allowed to merge pr #%d", ctx.opts.UserID, branchName, repo, pr.Index)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s", branchName),
+ })
+ return
+ }
+
+ // If we're an admin for the instance, we can ignore checks
+ if ctx.user.IsAdmin {
+ return
+ }
+
+ // It's not allowed t overwrite protected files. Unless if the user is an
+ // admin and the protected branch rule doesn't apply to admins.
+ if changedProtectedfiles && (!ctx.userPerm.IsAdmin() || protectBranch.ApplyToAdmins) {
+ log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath),
+ })
+ return
+ }
+
+ // Check all status checks and reviews are ok
+ if pb, err := pull_service.CheckPullBranchProtections(ctx, pr, true); err != nil {
+ if models.IsErrDisallowedToMerge(err) {
+ // Allow this if the rule doesn't apply to admins and the user is an admin.
+ if ctx.userPerm.IsAdmin() && !pb.ApplyToAdmins {
+ return
+ }
+ log.Warn("Forbidden: User %d is not allowed push to protected branch %s in %-v and pr #%d is not ready to be merged: %s", ctx.opts.UserID, branchName, repo, pr.Index, err.Error())
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s and pr #%d is not ready to be merged: %s", branchName, ctx.opts.PullRequestID, err.Error()),
+ })
+ return
+ }
+ log.Error("Unable to check if mergeable: protected branch %s in %-v and pr #%d. Error: %v", ctx.opts.UserID, branchName, repo, pr.Index, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get status of pull request %d. Error: %v", ctx.opts.PullRequestID, err),
+ })
+ return
+ }
+ }
+}
+
+func preReceiveTag(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { //nolint:unparam
+ if !ctx.AssertCanWriteCode() {
+ return
+ }
+
+ tagName := refFullName.TagName()
+
+ if !ctx.gotProtectedTags {
+ var err error
+ ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ log.Error("Unable to get protected tags for %-v Error: %v", ctx.Repo.Repository, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+ ctx.gotProtectedTags = true
+ }
+
+ isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+ if !isAllowed {
+ log.Warn("Forbidden: Tag %s in %-v is protected", tagName, ctx.Repo.Repository)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("Tag %s is protected", tagName),
+ })
+ return
+ }
+
+ // If the user is over quota, and the push isn't a tag deletion, deny it
+ if ctx.isOverQuota {
+ objectFormat := ctx.Repo.GetObjectFormat()
+ if newCommitID != objectFormat.EmptyObjectID().String() {
+ ctx.quotaExceeded()
+ return
+ }
+ }
+}
+
+func preReceiveFor(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { //nolint:unparam
+ if !ctx.AssertCreatePullRequest() {
+ return
+ }
+
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "Can't create pull request for an empty repository.",
+ })
+ return
+ }
+
+ if ctx.opts.IsWiki {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "Pull requests are not supported on the wiki.",
+ })
+ return
+ }
+
+ baseBranchName := refFullName.ForBranchName()
+
+ baseBranchExist := false
+ if ctx.Repo.GitRepo.IsBranchExist(baseBranchName) {
+ baseBranchExist = true
+ }
+
+ if !baseBranchExist {
+ for p, v := range baseBranchName {
+ if v == '/' && ctx.Repo.GitRepo.IsBranchExist(baseBranchName[:p]) && p != len(baseBranchName)-1 {
+ baseBranchExist = true
+ break
+ }
+ }
+ }
+
+ if !baseBranchExist {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("Unexpected ref: %s", refFullName),
+ })
+ return
+ }
+}
+
+func generateGitEnv(opts *private.HookOptions) (env []string) {
+ env = os.Environ()
+ if opts.GitAlternativeObjectDirectories != "" {
+ env = append(env,
+ private.GitAlternativeObjectDirectories+"="+opts.GitAlternativeObjectDirectories)
+ }
+ if opts.GitObjectDirectory != "" {
+ env = append(env,
+ private.GitObjectDirectory+"="+opts.GitObjectDirectory)
+ }
+ if opts.GitQuarantinePath != "" {
+ env = append(env,
+ private.GitQuarantinePath+"="+opts.GitQuarantinePath)
+ }
+ return env
+}
+
+// loadPusherAndPermission returns false if an error occurs, and it writes the error response
+func (ctx *preReceiveContext) loadPusherAndPermission() bool {
+ if ctx.loadedPusher {
+ return true
+ }
+
+ if ctx.opts.UserID == user_model.ActionsUserID {
+ ctx.user = user_model.NewActionsUser()
+ ctx.userPerm.AccessMode = perm_model.AccessMode(ctx.opts.ActionPerm)
+ if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil {
+ log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err),
+ })
+ return false
+ }
+ ctx.userPerm.Units = ctx.Repo.Repository.Units
+ ctx.userPerm.UnitsMode = make(map[unit.Type]perm_model.AccessMode)
+ for _, u := range ctx.Repo.Repository.Units {
+ ctx.userPerm.UnitsMode[u.Type] = ctx.userPerm.AccessMode
+ }
+ } else {
+ user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
+ if err != nil {
+ log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err),
+ })
+ return false
+ }
+ ctx.user = user
+ userPerm, err := access_model.GetUserRepoPermission(ctx, ctx.Repo.Repository, user)
+ if err != nil {
+ log.Error("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err),
+ })
+ return false
+ }
+ ctx.userPerm = userPerm
+ }
+
+ if ctx.opts.DeployKeyID != 0 {
+ deployKey, err := asymkey_model.GetDeployKeyByID(ctx, ctx.opts.DeployKeyID)
+ if err != nil {
+ log.Error("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err),
+ })
+ return false
+ }
+ ctx.deployKeyAccessMode = deployKey.Mode
+ }
+
+ ctx.loadedPusher = true
+ return true
+}
diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go
new file mode 100644
index 0000000..e4aabd8
--- /dev/null
+++ b/routers/private/hook_proc_receive.go
@@ -0,0 +1,43 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "net/http"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/agit"
+ gitea_context "code.gitea.io/gitea/services/context"
+)
+
+// HookProcReceive proc-receive hook - only handles agit Proc-Receive requests at present
+func HookProcReceive(ctx *gitea_context.PrivateContext) {
+ opts := web.GetForm(ctx).(*private.HookOptions)
+ if !git.SupportProcReceive {
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+
+ results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, opts)
+ if err != nil {
+ if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
+ ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
+ } else {
+ log.Error(err.Error())
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ }
+
+ return
+ }
+
+ ctx.JSON(http.StatusOK, private.HookProcReceiveResult{
+ Results: results,
+ })
+}
diff --git a/routers/private/hook_verification.go b/routers/private/hook_verification.go
new file mode 100644
index 0000000..764c976
--- /dev/null
+++ b/routers/private/hook_verification.go
@@ -0,0 +1,122 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// This file contains commit verification functions for refs passed across in hooks
+
+func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []string) error {
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ log.Error("Unable to create os.Pipe for %s", repo.Path)
+ return err
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ var command *git.Command
+ objectFormat, _ := repo.GetObjectFormat()
+ if oldCommitID == objectFormat.EmptyObjectID().String() {
+ // When creating a new branch, the oldCommitID is empty, by using "newCommitID --not --all":
+ // List commits that are reachable by following the newCommitID, exclude "all" existing heads/tags commits
+ // So, it only lists the new commits received, doesn't list the commits already present in the receiving repository
+ command = git.NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(newCommitID).AddArguments("--not", "--all")
+ } else {
+ command = git.NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(oldCommitID + "..." + newCommitID)
+ }
+ // This is safe as force pushes are already forbidden
+ err = command.Run(&git.RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env)
+ if err != nil {
+ log.Error("readAndVerifyCommitsFromShaReader failed: %v", err)
+ cancel()
+ }
+ _ = stdoutReader.Close()
+ return err
+ },
+ })
+ if err != nil && !isErrUnverifiedCommit(err) {
+ log.Error("Unable to check commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
+ }
+ return err
+}
+
+func readAndVerifyCommitsFromShaReader(input io.ReadCloser, repo *git.Repository, env []string) error {
+ scanner := bufio.NewScanner(input)
+ for scanner.Scan() {
+ line := scanner.Text()
+ err := readAndVerifyCommit(line, repo, env)
+ if err != nil {
+ return err
+ }
+ }
+ return scanner.Err()
+}
+
+func readAndVerifyCommit(sha string, repo *git.Repository, env []string) error {
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ if err != nil {
+ log.Error("Unable to create pipe for %s: %v", repo.Path, err)
+ return err
+ }
+ defer func() {
+ _ = stdoutReader.Close()
+ _ = stdoutWriter.Close()
+ }()
+
+ commitID := git.MustIDFromString(sha)
+
+ return git.NewCommand(repo.Ctx, "cat-file", "commit").AddDynamicArguments(sha).
+ Run(&git.RunOpts{
+ Env: env,
+ Dir: repo.Path,
+ Stdout: stdoutWriter,
+ PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
+ _ = stdoutWriter.Close()
+ commit, err := git.CommitFromReader(repo, commitID, stdoutReader)
+ if err != nil {
+ return err
+ }
+ verification := asymkey_model.ParseCommitWithSignature(ctx, commit)
+ if !verification.Verified {
+ cancel()
+ return &errUnverifiedCommit{
+ commit.ID.String(),
+ }
+ }
+ return nil
+ },
+ })
+}
+
+type errUnverifiedCommit struct {
+ sha string
+}
+
+func (e *errUnverifiedCommit) Error() string {
+ return fmt.Sprintf("Unverified commit: %s", e.sha)
+}
+
+func isErrUnverifiedCommit(err error) bool {
+ _, ok := err.(*errUnverifiedCommit)
+ return ok
+}
diff --git a/routers/private/hook_verification_test.go b/routers/private/hook_verification_test.go
new file mode 100644
index 0000000..5f0d1d0
--- /dev/null
+++ b/routers/private/hook_verification_test.go
@@ -0,0 +1,46 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/git"
+
+ "github.com/stretchr/testify/require"
+)
+
+var testReposDir = "tests/repos/"
+
+func TestVerifyCommits(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ gitRepo, err := git.OpenRepository(context.Background(), testReposDir+"repo1_hook_verification")
+ defer gitRepo.Close()
+ require.NoError(t, err)
+
+ objectFormat, err := gitRepo.GetObjectFormat()
+ require.NoError(t, err)
+
+ testCases := []struct {
+ base, head string
+ verified bool
+ }{
+ {"72920278f2f999e3005801e5d5b8ab8139d3641c", "d766f2917716d45be24bfa968b8409544941be32", true},
+ {objectFormat.EmptyObjectID().String(), "93eac826f6188f34646cea81bf426aa5ba7d3bfe", true}, // New branch with verified commit
+ {"9779d17a04f1e2640583d35703c62460b2d86e0a", "72920278f2f999e3005801e5d5b8ab8139d3641c", false},
+ {objectFormat.EmptyObjectID().String(), "9ce3f779ae33f31fce17fac3c512047b75d7498b", false}, // New branch with unverified commit
+ }
+
+ for _, tc := range testCases {
+ err = verifyCommits(tc.base, tc.head, gitRepo, nil)
+ if tc.verified {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ }
+ }
+}
diff --git a/routers/private/internal.go b/routers/private/internal.go
new file mode 100644
index 0000000..ede3101
--- /dev/null
+++ b/routers/private/internal.go
@@ -0,0 +1,84 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Package private contains all internal routes. The package name "internal" isn't usable because Golang reserves it for disabling cross-package usage.
+package private
+
+import (
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+
+ "gitea.com/go-chi/binding"
+ chi_middleware "github.com/go-chi/chi/v5/middleware"
+)
+
+// CheckInternalToken check internal token is set
+func CheckInternalToken(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ tokens := req.Header.Get("Authorization")
+ fields := strings.SplitN(tokens, " ", 2)
+ if setting.InternalToken == "" {
+ log.Warn(`The INTERNAL_TOKEN setting is missing from the configuration file: %q, internal API can't work.`, setting.CustomConf)
+ http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+ return
+ }
+ if len(fields) != 2 || fields[0] != "Bearer" || fields[1] != setting.InternalToken {
+ log.Debug("Forbidden attempt to access internal url: Authorization header: %s", tokens)
+ http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+ } else {
+ next.ServeHTTP(w, req)
+ }
+ })
+}
+
+// bind binding an obj to a handler
+func bind[T any](_ T) any {
+ return func(ctx *context.PrivateContext) {
+ theObj := new(T) // create a new form obj for every request but not use obj directly
+ binding.Bind(ctx.Req, theObj)
+ web.SetForm(ctx, theObj)
+ }
+}
+
+// Routes registers all internal APIs routes to web application.
+// These APIs will be invoked by internal commands for example `gitea serv` and etc.
+func Routes() *web.Route {
+ r := web.NewRoute()
+ r.Use(context.PrivateContexter())
+ r.Use(CheckInternalToken)
+ // Log the real ip address of the request from SSH is really helpful for diagnosing sometimes.
+ // Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers.
+ r.Use(chi_middleware.RealIP)
+
+ r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
+ r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
+ r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
+ r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
+ r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext, bind(private.HookOptions{}), HookPostReceive)
+ r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext, RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
+ r.Post("/hook/set-default-branch/{owner}/{repo}/{branch}", RepoAssignment, SetDefaultBranch)
+ r.Get("/serv/none/{keyid}", ServNoCommand)
+ r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)
+ r.Post("/manager/shutdown", Shutdown)
+ r.Post("/manager/restart", Restart)
+ r.Post("/manager/reload-templates", ReloadTemplates)
+ r.Post("/manager/flush-queues", bind(private.FlushOptions{}), FlushQueues)
+ r.Post("/manager/pause-logging", PauseLogging)
+ r.Post("/manager/resume-logging", ResumeLogging)
+ r.Post("/manager/release-and-reopen-logging", ReleaseReopenLogging)
+ r.Post("/manager/set-log-sql", SetLogSQL)
+ r.Post("/manager/add-logger", bind(private.LoggerOptions{}), AddLogger)
+ r.Post("/manager/remove-logger/{logger}/{writer}", RemoveLogger)
+ r.Get("/manager/processes", Processes)
+ r.Post("/mail/send", SendEmail)
+ r.Post("/restore_repo", RestoreRepo)
+ r.Post("/actions/generate_actions_runner_token", GenerateActionsRunnerToken)
+
+ return r
+}
diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go
new file mode 100644
index 0000000..e8ee8ba
--- /dev/null
+++ b/routers/private/internal_repo.go
@@ -0,0 +1,69 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ gitea_context "code.gitea.io/gitea/services/context"
+)
+
+// This file contains common functions relating to setting the Repository for the internal routes
+
+// RepoAssignment assigns the repository and gitrepository to the private context
+func RepoAssignment(ctx *gitea_context.PrivateContext) context.CancelFunc {
+ ownerName := ctx.Params(":owner")
+ repoName := ctx.Params(":repo")
+
+ repo := loadRepository(ctx, ownerName, repoName)
+ if ctx.Written() {
+ // Error handled in loadRepository
+ return nil
+ }
+
+ gitRepo, err := gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return nil
+ }
+
+ ctx.Repo = &gitea_context.Repository{
+ Repository: repo,
+ GitRepo: gitRepo,
+ }
+
+ // We opened it, we should close it
+ cancel := func() {
+ // If it's been set to nil then assume someone else has closed it.
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ }
+ }
+
+ return cancel
+}
+
+func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository {
+ repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
+ if err != nil {
+ log.Error("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to get repository: %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return nil
+ }
+ if repo.OwnerName == "" {
+ repo.OwnerName = ownerName
+ }
+ return repo
+}
diff --git a/routers/private/key.go b/routers/private/key.go
new file mode 100644
index 0000000..5b8f238
--- /dev/null
+++ b/routers/private/key.go
@@ -0,0 +1,61 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "net/http"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/services/context"
+)
+
+// UpdatePublicKeyInRepo update public key and deploy key updates
+func UpdatePublicKeyInRepo(ctx *context.PrivateContext) {
+ keyID := ctx.ParamsInt64(":id")
+ repoID := ctx.ParamsInt64(":repoid")
+ if err := asymkey_model.UpdatePublicKeyUpdated(ctx, keyID); err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+
+ deployKey, err := asymkey_model.GetDeployKeyByRepo(ctx, keyID, repoID)
+ if err != nil {
+ if asymkey_model.IsErrDeployKeyNotExist(err) {
+ ctx.PlainText(http.StatusOK, "success")
+ return
+ }
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+ deployKey.UpdatedUnix = timeutil.TimeStampNow()
+ if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// AuthorizedPublicKeyByContent searches content as prefix (leak e-mail part)
+// and returns public key found.
+func AuthorizedPublicKeyByContent(ctx *context.PrivateContext) {
+ content := ctx.FormString("content")
+
+ publicKey, err := asymkey_model.SearchPublicKeyByContent(ctx, content)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+ ctx.PlainText(http.StatusOK, publicKey.AuthorizedString())
+}
diff --git a/routers/private/mail.go b/routers/private/mail.go
new file mode 100644
index 0000000..cf3abb3
--- /dev/null
+++ b/routers/private/mail.go
@@ -0,0 +1,91 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ stdCtx "context"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/mailer"
+)
+
+// SendEmail pushes messages to mail queue
+//
+// It doesn't wait before each message will be processed
+func SendEmail(ctx *context.PrivateContext) {
+ if setting.MailService == nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: "Mail service is not enabled.",
+ })
+ return
+ }
+
+ var mail private.Email
+ rd := ctx.Req.Body
+ defer rd.Close()
+
+ if err := json.NewDecoder(rd).Decode(&mail); err != nil {
+ log.Error("JSON Decode failed: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+
+ var emails []string
+ if len(mail.To) > 0 {
+ for _, uname := range mail.To {
+ user, err := user_model.GetUserByName(ctx, uname)
+ if err != nil {
+ err := fmt.Sprintf("Failed to get user information: %v", err)
+ log.Error(err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err,
+ })
+ return
+ }
+
+ if user != nil && len(user.Email) > 0 {
+ emails = append(emails, user.Email)
+ }
+ }
+ } else {
+ err := db.Iterate(ctx, nil, func(ctx stdCtx.Context, user *user_model.User) error {
+ if len(user.Email) > 0 && user.IsActive {
+ emails = append(emails, user.Email)
+ }
+ return nil
+ })
+ if err != nil {
+ err := fmt.Sprintf("Failed to find users: %v", err)
+ log.Error(err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err,
+ })
+ return
+ }
+ }
+
+ sendEmail(ctx, mail.Subject, mail.Message, emails)
+}
+
+func sendEmail(ctx *context.PrivateContext, subject, message string, to []string) {
+ for _, email := range to {
+ msg := mailer.NewMessage(email, subject, message)
+ mailer.SendAsync(msg)
+ }
+
+ wasSent := strconv.Itoa(len(to))
+
+ ctx.PlainText(http.StatusOK, wasSent)
+}
diff --git a/routers/private/main_test.go b/routers/private/main_test.go
new file mode 100644
index 0000000..a6bec72
--- /dev/null
+++ b/routers/private/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/routers/private/manager.go b/routers/private/manager.go
new file mode 100644
index 0000000..a6aa03e
--- /dev/null
+++ b/routers/private/manager.go
@@ -0,0 +1,195 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/graceful/releasereopen"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+)
+
+// ReloadTemplates reloads all the templates
+func ReloadTemplates(ctx *context.PrivateContext) {
+ err := templates.ReloadHTMLTemplates()
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ UserMsg: fmt.Sprintf("Template error: %v", err),
+ })
+ return
+ }
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// FlushQueues flushes all the Queues
+func FlushQueues(ctx *context.PrivateContext) {
+ opts := web.GetForm(ctx).(*private.FlushOptions)
+ if opts.NonBlocking {
+ // Save the hammer ctx here - as a new one is created each time you call this.
+ baseCtx := graceful.GetManager().HammerContext()
+ go func() {
+ err := queue.GetManager().FlushAll(baseCtx, opts.Timeout)
+ if err != nil {
+ log.Error("Flushing request timed-out with error: %v", err)
+ }
+ }()
+ ctx.JSON(http.StatusAccepted, private.Response{
+ UserMsg: "Flushing",
+ })
+ return
+ }
+ err := queue.GetManager().FlushAll(ctx, opts.Timeout)
+ if err != nil {
+ ctx.JSON(http.StatusRequestTimeout, private.Response{
+ UserMsg: fmt.Sprintf("%v", err),
+ })
+ }
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// PauseLogging pauses logging
+func PauseLogging(ctx *context.PrivateContext) {
+ log.GetManager().PauseAll()
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// ResumeLogging resumes logging
+func ResumeLogging(ctx *context.PrivateContext) {
+ log.GetManager().ResumeAll()
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// ReleaseReopenLogging releases and reopens logging files
+func ReleaseReopenLogging(ctx *context.PrivateContext) {
+ if err := releasereopen.GetManager().ReleaseReopen(); err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Error during release and reopen: %v", err),
+ })
+ return
+ }
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// SetLogSQL re-sets database SQL logging
+func SetLogSQL(ctx *context.PrivateContext) {
+ db.SetLogSQL(ctx, ctx.FormBool("on"))
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// RemoveLogger removes a logger
+func RemoveLogger(ctx *context.PrivateContext) {
+ logger := ctx.Params("logger")
+ writer := ctx.Params("writer")
+ err := log.GetManager().GetLogger(logger).RemoveWriter(writer)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to remove log writer: %s %s %v", logger, writer, err),
+ })
+ return
+ }
+ ctx.PlainText(http.StatusOK, fmt.Sprintf("Removed %s %s", logger, writer))
+}
+
+// AddLogger adds a logger
+func AddLogger(ctx *context.PrivateContext) {
+ opts := web.GetForm(ctx).(*private.LoggerOptions)
+
+ if len(opts.Logger) == 0 {
+ opts.Logger = log.DEFAULT
+ }
+
+ writerMode := log.WriterMode{}
+ writerType := opts.Mode
+
+ var flags string
+ var ok bool
+ if flags, ok = opts.Config["flags"].(string); !ok {
+ switch opts.Logger {
+ case "access":
+ flags = ""
+ case "router":
+ flags = "date,time"
+ default:
+ flags = "stdflags"
+ }
+ }
+ writerMode.Flags = log.FlagsFromString(flags)
+
+ if writerMode.Colorize, ok = opts.Config["colorize"].(bool); !ok && opts.Mode == "console" {
+ if _, ok := opts.Config["stderr"]; ok {
+ writerMode.Colorize = log.CanColorStderr
+ } else {
+ writerMode.Colorize = log.CanColorStdout
+ }
+ }
+
+ writerMode.Level = setting.Log.Level
+ if level, ok := opts.Config["level"].(string); ok {
+ writerMode.Level = log.LevelFromString(level)
+ }
+
+ writerMode.StacktraceLevel = setting.Log.StacktraceLogLevel
+ if stacktraceLevel, ok := opts.Config["level"].(string); ok {
+ writerMode.StacktraceLevel = log.LevelFromString(stacktraceLevel)
+ }
+
+ writerMode.Prefix, _ = opts.Config["prefix"].(string)
+ writerMode.Expression, _ = opts.Config["expression"].(string)
+
+ switch writerType {
+ case "console":
+ writerOption := log.WriterConsoleOption{}
+ writerOption.Stderr, _ = opts.Config["stderr"].(bool)
+ writerMode.WriterOption = writerOption
+ case "file":
+ writerOption := log.WriterFileOption{}
+ fileName, _ := opts.Config["filename"].(string)
+ writerOption.FileName = setting.LogPrepareFilenameForWriter(fileName, opts.Writer+".log")
+ writerOption.LogRotate, _ = opts.Config["rotate"].(bool)
+ maxSizeShift, _ := opts.Config["maxsize"].(int)
+ if maxSizeShift == 0 {
+ maxSizeShift = 28
+ }
+ writerOption.MaxSize = 1 << maxSizeShift
+ writerOption.DailyRotate, _ = opts.Config["daily"].(bool)
+ writerOption.MaxDays, _ = opts.Config["maxdays"].(int)
+ if writerOption.MaxDays == 0 {
+ writerOption.MaxDays = 7
+ }
+ writerOption.Compress, _ = opts.Config["compress"].(bool)
+ writerOption.CompressionLevel, _ = opts.Config["compressionLevel"].(int)
+ if writerOption.CompressionLevel == 0 {
+ writerOption.CompressionLevel = -1
+ }
+ writerMode.WriterOption = writerOption
+ case "conn":
+ writerOption := log.WriterConnOption{}
+ writerOption.ReconnectOnMsg, _ = opts.Config["reconnectOnMsg"].(bool)
+ writerOption.Reconnect, _ = opts.Config["reconnect"].(bool)
+ writerOption.Protocol, _ = opts.Config["net"].(string)
+ writerOption.Addr, _ = opts.Config["address"].(string)
+ writerMode.WriterOption = writerOption
+ default:
+ panic(fmt.Sprintf("invalid log writer mode: %s", writerType))
+ }
+ writer, err := log.NewEventWriter(opts.Writer, writerType, writerMode)
+ if err != nil {
+ log.Error("Failed to create new log writer: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to create new log writer: %v", err),
+ })
+ return
+ }
+ log.GetManager().GetLogger(opts.Logger).AddWriters(writer)
+ ctx.PlainText(http.StatusOK, "success")
+}
diff --git a/routers/private/manager_process.go b/routers/private/manager_process.go
new file mode 100644
index 0000000..9a0298a
--- /dev/null
+++ b/routers/private/manager_process.go
@@ -0,0 +1,160 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "runtime"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ process_module "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/services/context"
+)
+
+// Processes prints out the processes
+func Processes(ctx *context.PrivateContext) {
+ pid := ctx.FormString("cancel-pid")
+ if pid != "" {
+ process_module.GetManager().Cancel(process_module.IDType(pid))
+ runtime.Gosched()
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ flat := ctx.FormBool("flat")
+ noSystem := ctx.FormBool("no-system")
+ stacktraces := ctx.FormBool("stacktraces")
+ json := ctx.FormBool("json")
+
+ var processes []*process_module.Process
+ goroutineCount := int64(0)
+ var processCount int
+ var err error
+ if stacktraces {
+ processes, processCount, goroutineCount, err = process_module.GetManager().ProcessStacktraces(flat, noSystem)
+ if err != nil {
+ log.Error("Unable to get stacktrace: %v", err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to get stacktraces: %v", err),
+ })
+ return
+ }
+ } else {
+ processes, processCount = process_module.GetManager().Processes(flat, noSystem)
+ }
+
+ if json {
+ ctx.JSON(http.StatusOK, map[string]any{
+ "TotalNumberOfGoroutines": goroutineCount,
+ "TotalNumberOfProcesses": processCount,
+ "Processes": processes,
+ })
+ return
+ }
+
+ ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
+ ctx.Resp.WriteHeader(http.StatusOK)
+
+ if err := writeProcesses(ctx.Resp, processes, processCount, goroutineCount, "", flat); err != nil {
+ log.Error("Unable to write out process stacktrace: %v", err)
+ if !ctx.Written() {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to get stacktraces: %v", err),
+ })
+ }
+ return
+ }
+}
+
+func writeProcesses(out io.Writer, processes []*process_module.Process, processCount int, goroutineCount int64, indent string, flat bool) error {
+ if goroutineCount > 0 {
+ if _, err := fmt.Fprintf(out, "%sTotal Number of Goroutines: %d\n", indent, goroutineCount); err != nil {
+ return err
+ }
+ }
+ if _, err := fmt.Fprintf(out, "%sTotal Number of Processes: %d\n", indent, processCount); err != nil {
+ return err
+ }
+ if len(processes) > 0 {
+ if err := writeProcess(out, processes[0], " ", flat); err != nil {
+ return err
+ }
+ }
+ if len(processes) > 1 {
+ for _, process := range processes[1:] {
+ if _, err := fmt.Fprintf(out, "%s | \n", indent); err != nil {
+ return err
+ }
+ if err := writeProcess(out, process, " ", flat); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func writeProcess(out io.Writer, process *process_module.Process, indent string, flat bool) error {
+ sb := &bytes.Buffer{}
+ if flat {
+ if process.ParentPID != "" {
+ _, _ = fmt.Fprintf(sb, "%s+ PID: %s\t\tType: %s\n", indent, process.PID, process.Type)
+ } else {
+ _, _ = fmt.Fprintf(sb, "%s+ PID: %s:%s\tType: %s\n", indent, process.ParentPID, process.PID, process.Type)
+ }
+ } else {
+ _, _ = fmt.Fprintf(sb, "%s+ PID: %s\tType: %s\n", indent, process.PID, process.Type)
+ }
+ indent += "| "
+
+ _, _ = fmt.Fprintf(sb, "%sDescription: %s\n", indent, process.Description)
+ _, _ = fmt.Fprintf(sb, "%sStart: %s\n", indent, process.Start)
+
+ if len(process.Stacks) > 0 {
+ _, _ = fmt.Fprintf(sb, "%sGoroutines:\n", indent)
+ for _, stack := range process.Stacks {
+ indent := indent + " "
+ _, _ = fmt.Fprintf(sb, "%s+ Description: %s", indent, stack.Description)
+ if stack.Count > 1 {
+ _, _ = fmt.Fprintf(sb, "* %d", stack.Count)
+ }
+ _, _ = fmt.Fprintf(sb, "\n")
+ indent += "| "
+ if len(stack.Labels) > 0 {
+ _, _ = fmt.Fprintf(sb, "%sLabels: %q:%q", indent, stack.Labels[0].Name, stack.Labels[0].Value)
+
+ if len(stack.Labels) > 1 {
+ for _, label := range stack.Labels[1:] {
+ _, _ = fmt.Fprintf(sb, ", %q:%q", label.Name, label.Value)
+ }
+ }
+ _, _ = fmt.Fprintf(sb, "\n")
+ }
+ _, _ = fmt.Fprintf(sb, "%sStack:\n", indent)
+ indent += " "
+ for _, entry := range stack.Entry {
+ _, _ = fmt.Fprintf(sb, "%s+ %s\n", indent, entry.Function)
+ _, _ = fmt.Fprintf(sb, "%s| %s:%d\n", indent, entry.File, entry.Line)
+ }
+ }
+ }
+ if _, err := out.Write(sb.Bytes()); err != nil {
+ return err
+ }
+ sb.Reset()
+ if len(process.Children) > 0 {
+ if _, err := fmt.Fprintf(out, "%sChildren:\n", indent); err != nil {
+ return err
+ }
+ for _, child := range process.Children {
+ if err := writeProcess(out, child, indent+" ", flat); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/routers/private/manager_unix.go b/routers/private/manager_unix.go
new file mode 100644
index 0000000..0c63ebc
--- /dev/null
+++ b/routers/private/manager_unix.go
@@ -0,0 +1,25 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package private
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/services/context"
+)
+
+// Restart causes the server to perform a graceful restart
+func Restart(ctx *context.PrivateContext) {
+ graceful.GetManager().DoGracefulRestart()
+ ctx.PlainText(http.StatusOK, "success")
+}
+
+// Shutdown causes the server to perform a graceful shutdown
+func Shutdown(ctx *context.PrivateContext) {
+ graceful.GetManager().DoGracefulShutdown()
+ ctx.PlainText(http.StatusOK, "success")
+}
diff --git a/routers/private/manager_windows.go b/routers/private/manager_windows.go
new file mode 100644
index 0000000..f1b9365
--- /dev/null
+++ b/routers/private/manager_windows.go
@@ -0,0 +1,27 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build windows
+
+package private
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/services/context"
+)
+
+// Restart is not implemented for Windows based servers as they can't fork
+func Restart(ctx *context.PrivateContext) {
+ ctx.JSON(http.StatusNotImplemented, private.Response{
+ UserMsg: "windows servers cannot be gracefully restarted - shutdown and restart manually",
+ })
+}
+
+// Shutdown causes the server to perform a graceful shutdown
+func Shutdown(ctx *context.PrivateContext) {
+ graceful.GetManager().DoGracefulShutdown()
+ ctx.PlainText(http.StatusOK, "success")
+}
diff --git a/routers/private/restore_repo.go b/routers/private/restore_repo.go
new file mode 100644
index 0000000..4e95d30
--- /dev/null
+++ b/routers/private/restore_repo.go
@@ -0,0 +1,53 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "io"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/private"
+ myCtx "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/migrations"
+)
+
+// RestoreRepo restore a repository from data
+func RestoreRepo(ctx *myCtx.PrivateContext) {
+ bs, err := io.ReadAll(ctx.Req.Body)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+ params := struct {
+ RepoDir string
+ OwnerName string
+ RepoName string
+ Units []string
+ Validation bool
+ }{}
+ if err = json.Unmarshal(bs, &params); err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+
+ if err := migrations.RestoreRepository(
+ ctx,
+ params.RepoDir,
+ params.OwnerName,
+ params.RepoName,
+ params.Units,
+ params.Validation,
+ ); err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ } else {
+ ctx.PlainText(http.StatusOK, "success")
+ }
+}
diff --git a/routers/private/serv.go b/routers/private/serv.go
new file mode 100644
index 0000000..ef3920d
--- /dev/null
+++ b/routers/private/serv.go
@@ -0,0 +1,397 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/perm"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+ wiki_service "code.gitea.io/gitea/services/wiki"
+)
+
+// ServNoCommand returns information about the provided keyid
+func ServNoCommand(ctx *context.PrivateContext) {
+ keyID := ctx.ParamsInt64(":keyid")
+ if keyID <= 0 {
+ ctx.JSON(http.StatusBadRequest, private.Response{
+ UserMsg: fmt.Sprintf("Bad key id: %d", keyID),
+ })
+ }
+ results := private.KeyAndOwner{}
+
+ key, err := asymkey_model.GetPublicKeyByID(ctx, keyID)
+ if err != nil {
+ if asymkey_model.IsErrKeyNotExist(err) {
+ ctx.JSON(http.StatusUnauthorized, private.Response{
+ UserMsg: fmt.Sprintf("Cannot find key: %d", keyID),
+ })
+ return
+ }
+ log.Error("Unable to get public key: %d Error: %v", keyID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+ results.Key = key
+
+ if key.Type == asymkey_model.KeyTypeUser || key.Type == asymkey_model.KeyTypePrincipal {
+ user, err := user_model.GetUserByID(ctx, key.OwnerID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.JSON(http.StatusUnauthorized, private.Response{
+ UserMsg: fmt.Sprintf("Cannot find owner with id: %d for key: %d", key.OwnerID, keyID),
+ })
+ return
+ }
+ log.Error("Unable to get owner with id: %d for public key: %d Error: %v", key.OwnerID, keyID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: err.Error(),
+ })
+ return
+ }
+ if !user.IsActive || user.ProhibitLogin {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "Your account is disabled.",
+ })
+ return
+ }
+ results.Owner = user
+ }
+ ctx.JSON(http.StatusOK, &results)
+}
+
+// ServCommand returns information about the provided keyid
+func ServCommand(ctx *context.PrivateContext) {
+ keyID := ctx.ParamsInt64(":keyid")
+ ownerName := ctx.Params(":owner")
+ repoName := ctx.Params(":repo")
+ mode := perm.AccessMode(ctx.FormInt("mode"))
+
+ // Set the basic parts of the results to return
+ results := private.ServCommandResults{
+ RepoName: repoName,
+ OwnerName: ownerName,
+ KeyID: keyID,
+ }
+
+ // Now because we're not translating things properly let's just default some English strings here
+ modeString := "read"
+ if mode > perm.AccessModeRead {
+ modeString = "write to"
+ }
+
+ // The default unit we're trying to look at is code
+ unitType := unit.TypeCode
+
+ // Unless we're a wiki...
+ if strings.HasSuffix(repoName, ".wiki") {
+ // in which case we need to look at the wiki
+ unitType = unit.TypeWiki
+ // And we'd better munge the reponame and tell downstream we're looking at a wiki
+ results.IsWiki = true
+ results.RepoName = repoName[:len(repoName)-5]
+ }
+
+ owner, err := user_model.GetUserByName(ctx, results.OwnerName)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ // User is fetching/cloning a non-existent repository
+ log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr())
+ ctx.JSON(http.StatusNotFound, private.Response{
+ UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName),
+ })
+ return
+ }
+ log.Error("Unable to get repository owner: %s/%s Error: %v", results.OwnerName, results.RepoName, err)
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("Unable to get repository owner: %s/%s %v", results.OwnerName, results.RepoName, err),
+ })
+ return
+ }
+ if !owner.IsOrganization() && !owner.IsActive {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "Repository cannot be accessed, you could retry it later",
+ })
+ return
+ }
+
+ // Now get the Repository and set the results section
+ repoExist := true
+ repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, results.RepoName)
+ if err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ repoExist = false
+ for _, verb := range ctx.FormStrings("verb") {
+ if verb == "git-upload-pack" {
+ // User is fetching/cloning a non-existent repository
+ log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr())
+ ctx.JSON(http.StatusNotFound, private.Response{
+ UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName),
+ })
+ return
+ }
+ }
+ } else {
+ log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get repository: %s/%s %v", results.OwnerName, results.RepoName, err),
+ })
+ return
+ }
+ }
+
+ if repoExist {
+ repo.Owner = owner
+ repo.OwnerName = ownerName
+ results.RepoID = repo.ID
+
+ if repo.IsBeingCreated() {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: "Repository is being created, you could retry after it finished",
+ })
+ return
+ }
+
+ if repo.IsBroken() {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: "Repository is in a broken state",
+ })
+ return
+ }
+
+ // We can shortcut at this point if the repo is a mirror
+ if mode > perm.AccessModeRead && repo.IsMirror {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: fmt.Sprintf("Mirror Repository %s/%s is read-only", results.OwnerName, results.RepoName),
+ })
+ return
+ }
+ }
+
+ // Get the Public Key represented by the keyID
+ key, err := asymkey_model.GetPublicKeyByID(ctx, keyID)
+ if err != nil {
+ if asymkey_model.IsErrKeyNotExist(err) {
+ ctx.JSON(http.StatusNotFound, private.Response{
+ UserMsg: fmt.Sprintf("Cannot find key: %d", keyID),
+ })
+ return
+ }
+ log.Error("Unable to get public key: %d Error: %v", keyID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get key: %d Error: %v", keyID, err),
+ })
+ return
+ }
+ results.KeyName = key.Name
+ results.KeyID = key.ID
+ results.UserID = key.OwnerID
+
+ // If repo doesn't exist, deploy key doesn't make sense
+ if !repoExist && key.Type == asymkey_model.KeyTypeDeploy {
+ ctx.JSON(http.StatusNotFound, private.Response{
+ UserMsg: fmt.Sprintf("Cannot find repository %s/%s", results.OwnerName, results.RepoName),
+ })
+ return
+ }
+
+ // Deploy Keys have ownerID set to 0 therefore we can't use the owner
+ // So now we need to check if the key is a deploy key
+ // We'll keep hold of the deploy key here for permissions checking
+ var deployKey *asymkey_model.DeployKey
+ var user *user_model.User
+ if key.Type == asymkey_model.KeyTypeDeploy {
+ var err error
+ deployKey, err = asymkey_model.GetDeployKeyByRepo(ctx, key.ID, repo.ID)
+ if err != nil {
+ if asymkey_model.IsErrDeployKeyNotExist(err) {
+ ctx.JSON(http.StatusNotFound, private.Response{
+ UserMsg: fmt.Sprintf("Public (Deploy) Key: %d:%s is not authorized to %s %s/%s.", key.ID, key.Name, modeString, results.OwnerName, results.RepoName),
+ })
+ return
+ }
+ log.Error("Unable to get deploy for public (deploy) key: %d in %-v Error: %v", key.ID, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get Deploy Key for Public Key: %d:%s in %s/%s.", key.ID, key.Name, results.OwnerName, results.RepoName),
+ })
+ return
+ }
+ results.DeployKeyID = deployKey.ID
+ results.KeyName = deployKey.Name
+
+ // FIXME: Deploy keys aren't really the owner of the repo pushing changes
+ // however we don't have good way of representing deploy keys in hook.go
+ // so for now use the owner of the repository
+ results.UserName = results.OwnerName
+ results.UserID = repo.OwnerID
+ if !repo.Owner.KeepEmailPrivate {
+ results.UserEmail = repo.Owner.Email
+ }
+ } else {
+ // Get the user represented by the Key
+ var err error
+ user, err = user_model.GetUserByID(ctx, key.OwnerID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.JSON(http.StatusUnauthorized, private.Response{
+ UserMsg: fmt.Sprintf("Public Key: %d:%s owner %d does not exist.", key.ID, key.Name, key.OwnerID),
+ })
+ return
+ }
+ log.Error("Unable to get owner: %d for public key: %d:%s Error: %v", key.OwnerID, key.ID, key.Name, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get Owner: %d for Deploy Key: %d:%s in %s/%s.", key.OwnerID, key.ID, key.Name, ownerName, repoName),
+ })
+ return
+ }
+
+ if !user.IsActive || user.ProhibitLogin {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "Your account is disabled.",
+ })
+ return
+ }
+
+ results.UserName = user.Name
+ if !user.KeepEmailPrivate {
+ results.UserEmail = user.Email
+ }
+ }
+
+ // Don't allow pushing if the repo is archived
+ if repoExist && mode > perm.AccessModeRead && repo.IsArchived {
+ ctx.JSON(http.StatusUnauthorized, private.Response{
+ UserMsg: fmt.Sprintf("Repo: %s/%s is archived.", results.OwnerName, results.RepoName),
+ })
+ return
+ }
+
+ // Permissions checking:
+ if repoExist &&
+ (mode > perm.AccessModeRead ||
+ repo.IsPrivate ||
+ owner.Visibility.IsPrivate() ||
+ (user != nil && user.IsRestricted) || // user will be nil if the key is a deploykey
+ setting.Service.RequireSignInView) {
+ if key.Type == asymkey_model.KeyTypeDeploy {
+ if deployKey.Mode < mode {
+ ctx.JSON(http.StatusUnauthorized, private.Response{
+ UserMsg: fmt.Sprintf("Deploy Key: %d:%s is not authorized to %s %s/%s.", key.ID, key.Name, modeString, results.OwnerName, results.RepoName),
+ })
+ return
+ }
+ } else {
+ // Because of the special ref "refs/for" we will need to delay write permission check
+ if git.SupportProcReceive && unitType == unit.TypeCode {
+ mode = perm.AccessModeRead
+ }
+
+ perm, err := access_model.GetUserRepoPermission(ctx, repo, user)
+ if err != nil {
+ log.Error("Unable to get permissions for %-v with key %d in %-v Error: %v", user, key.ID, repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get permissions for user %d:%s with key %d in %s/%s Error: %v", user.ID, user.Name, key.ID, results.OwnerName, results.RepoName, err),
+ })
+ return
+ }
+
+ userMode := perm.UnitAccessMode(unitType)
+
+ if userMode < mode {
+ log.Warn("Failed authentication attempt for %s with key %s (not authorized to %s %s/%s) from %s", user.Name, key.Name, modeString, ownerName, repoName, ctx.RemoteAddr())
+ ctx.JSON(http.StatusUnauthorized, private.Response{
+ UserMsg: fmt.Sprintf("User: %d:%s with Key: %d:%s is not authorized to %s %s/%s.", user.ID, user.Name, key.ID, key.Name, modeString, ownerName, repoName),
+ })
+ return
+ }
+ }
+ }
+
+ // We already know we aren't using a deploy key
+ if !repoExist {
+ owner, err := user_model.GetUserByName(ctx, ownerName)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get owner: %s %v", results.OwnerName, err),
+ })
+ return
+ }
+
+ if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "Push to create is not enabled for organizations.",
+ })
+ return
+ }
+ if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "Push to create is not enabled for users.",
+ })
+ return
+ }
+
+ repo, err = repo_service.PushCreateRepo(ctx, user, owner, results.RepoName)
+ if err != nil {
+ log.Error("pushCreateRepo: %v", err)
+ ctx.JSON(http.StatusNotFound, private.Response{
+ UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName),
+ })
+ return
+ }
+ results.RepoID = repo.ID
+ }
+
+ if results.IsWiki {
+ // Ensure the wiki is enabled before we allow access to it
+ if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil {
+ if repo_model.IsErrUnitTypeNotExist(err) {
+ ctx.JSON(http.StatusForbidden, private.Response{
+ UserMsg: "repository wiki is disabled",
+ })
+ return
+ }
+ log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to get the wiki unit in %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+
+ // Finally if we're trying to touch the wiki we should init it
+ if err = wiki_service.InitWiki(ctx, repo); err != nil {
+ log.Error("Failed to initialize the wiki in %-v Error: %v", repo, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Failed to initialize the wiki in %s/%s Error: %v", ownerName, repoName, err),
+ })
+ return
+ }
+ }
+ log.Debug("Serv Results:\nIsWiki: %t\nDeployKeyID: %d\nKeyID: %d\tKeyName: %s\nUserName: %s\nUserID: %d\nOwnerName: %s\nRepoName: %s\nRepoID: %d",
+ results.IsWiki,
+ results.DeployKeyID,
+ results.KeyID,
+ results.KeyName,
+ results.UserName,
+ results.UserID,
+ results.OwnerName,
+ results.RepoName,
+ results.RepoID)
+
+ ctx.JSON(http.StatusOK, results)
+ // We will update the keys in a different call.
+}
diff --git a/routers/private/ssh_log.go b/routers/private/ssh_log.go
new file mode 100644
index 0000000..5bec632
--- /dev/null
+++ b/routers/private/ssh_log.go
@@ -0,0 +1,33 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package private
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/private"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+)
+
+// SSHLog hook to response ssh log
+func SSHLog(ctx *context.PrivateContext) {
+ if !setting.Log.EnableSSHLog {
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ opts := web.GetForm(ctx).(*private.SSHLogOption)
+
+ if opts.IsError {
+ log.Error("ssh: %v", opts.Message)
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ log.Debug("ssh: %v", opts.Message)
+ ctx.Status(http.StatusOK)
+}
diff --git a/routers/private/tests/repos/repo1_hook_verification/HEAD b/routers/private/tests/repos/repo1_hook_verification/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/routers/private/tests/repos/repo1_hook_verification/config b/routers/private/tests/repos/repo1_hook_verification/config
new file mode 100644
index 0000000..64280b8
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = true
+ symlinks = false
+ ignorecase = true
diff --git a/routers/private/tests/repos/repo1_hook_verification/info/refs b/routers/private/tests/repos/repo1_hook_verification/info/refs
new file mode 100644
index 0000000..ee593c4
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/info/refs
@@ -0,0 +1 @@
+d766f2917716d45be24bfa968b8409544941be32 refs/heads/main
diff --git a/routers/private/tests/repos/repo1_hook_verification/logs/HEAD b/routers/private/tests/repos/repo1_hook_verification/logs/HEAD
new file mode 100644
index 0000000..5c549b9
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/logs/HEAD
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 d766f2917716d45be24bfa968b8409544941be32 Gitea <gitea@fake.local> 1693148474 +0800 push
diff --git a/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main b/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main
new file mode 100644
index 0000000..5c549b9
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 d766f2917716d45be24bfa968b8409544941be32 Gitea <gitea@fake.local> 1693148474 +0800 push
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c b/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c
new file mode 100644
index 0000000..d55278f
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a b/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a
new file mode 100644
index 0000000..d8b6020
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e b/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e
new file mode 100644
index 0000000..77936d8
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 b/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86
new file mode 100644
index 0000000..5ec09ac
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 b/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7
new file mode 100644
index 0000000..355b88e
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 b/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7
new file mode 100644
index 0000000..ba1f06f
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c b/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c
new file mode 100644
index 0000000..4705fbf
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c
@@ -0,0 +1,2 @@
+xK
+1] AS$"32 ooW{!`JC%. $r]sѱe$mM)(O`btlE[:;4H1_rayl~EL@cXvM":MۃG_}? \ No newline at end of file
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd b/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd
new file mode 100644
index 0000000..fd3c3a4
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe b/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe
new file mode 100644
index 0000000..80abd3a
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a b/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a
new file mode 100644
index 0000000..7f3293a
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a
@@ -0,0 +1,2 @@
+x1
+!ES{AwGGa 9EQg W#AZ/p((Bhۼ&:pLY`U-z\ZM:xJ/G}:3 \ No newline at end of file
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b b/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b
new file mode 100644
index 0000000..61a9ee8
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 b/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141
new file mode 100644
index 0000000..fb79dc9
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 b/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9
new file mode 100644
index 0000000..1801a7f
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c b/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c
new file mode 100644
index 0000000..a765d66
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa b/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa
new file mode 100644
index 0000000..c7de09f
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa
@@ -0,0 +1,3 @@
+xA
+0E]$L xL2]
+ \}e[:{MZ5b8$v fR37];ˆbt 3$,tXG>m p1w͗-7pĄpZsDZL̾Le@ \ No newline at end of file
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 b/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32
new file mode 100644
index 0000000..1544584
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32
@@ -0,0 +1,3 @@
+xˮFE3+zn%44!%QxۀsA` 8{2IMjdf2"$e
+-( !J"a@BaHo3V<$/)$JJDBH{ # RROnfFO
+q[2̇~zjjL}prmFqh `@ث՘f?3[7) ^uֿ,l7zr|&Ou49:Qj1x6Q%tsV| (V,aL,G~r@`$[! Xˊep[8 o(kZγyeйYkd63;3 Ri ދdYDk91V]/C#&poFb}uW&]+m xaqdIX3 3KI#i_rgĩ7=`@[&A̤Lo3~M8MGt>xvQ(aWo"srzeŭ}QD֨fK)mr>>̚$F8x ^J k{mczI*^Mb m6M~hp {0 ]€?nUwgɠJ б<72 \ No newline at end of file
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 b/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451
new file mode 100644
index 0000000..b3f925e
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
new file mode 100644
index 0000000..7112238
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb b/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb
new file mode 100644
index 0000000..24580f8
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/info/packs b/routers/private/tests/repos/repo1_hook_verification/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c
new file mode 100644
index 0000000..d55278f
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a
new file mode 100644
index 0000000..d8b6020
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e
new file mode 100644
index 0000000..77936d8
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86
new file mode 100644
index 0000000..5ec09ac
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd
new file mode 100644
index 0000000..fd3c3a4
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe
new file mode 100644
index 0000000..80abd3a
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b
new file mode 100644
index 0000000..61a9ee8
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141
new file mode 100644
index 0000000..fb79dc9
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c
new file mode 100644
index 0000000..a765d66
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c
Binary files differ
diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa
new file mode 100644
index 0000000..c7de09f
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa
@@ -0,0 +1,3 @@
+xA
+0E]$L xL2]
+ \}e[:{MZ5b8$v fR37];ˆbt 3$,tXG>m p1w͗-7pĄpZsDZL̾Le@ \ No newline at end of file
diff --git a/routers/private/tests/repos/repo1_hook_verification/refs/heads/main b/routers/private/tests/repos/repo1_hook_verification/refs/heads/main
new file mode 100644
index 0000000..2186e82
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification/refs/heads/main
@@ -0,0 +1 @@
+d766f2917716d45be24bfa968b8409544941be32
diff --git a/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt b/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt
new file mode 100644
index 0000000..3061c75
--- /dev/null
+++ b/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt
@@ -0,0 +1,127 @@
+# GPG key for abcde@gitea.com
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQGNBGTrY3UBDAC2HLBqmMplAV15qSnC7g1c4dV406f5EHNhFr95Nup2My6b2eaf
+Tlvedv77s8PT/I7F3fy4apOZs5A7w2SsPlLMcQ3ev4uGOsxRtkq5RLy1Yb6SNueX
+0Da2UVKR5KTC5Q6BWaqxwS0IjKOLZ/xz0Pbe/ClV3bZSKBEY2omkVo3Z0HZ771vB
+2clPRvGJ/IdeKOsZ3ZytSFXfyiJBdARmeSPmydXLil8+Ibq5iLAeow5PK8hK1TCO
+nKHzLWNqcNq70tyjoHvcGi70iGjoVEEUgPCLLuU8WmzTJwlvA3BuDzjtaO7TLo/j
+dE6iqkHtMSS8x+43sAH6hcFRCWAVh/0Uq7n36uGDfNxGnX3YrmX3LR9x5IsBES1r
+GGWbpxio4o5GIf/Xd+JgDd9rzJCqRuZ3/sW/TxK38htWaVNZV0kMkHUCTc1ctzWp
+Cm635hbFCHBhPYIp+/z206khkAKDbz/CNuU91Wazsh7KO07wrwDtxfDDbInJ8TfH
+E2TGjzjQzgChfmcAEQEAAbQXYWJjZGUgPGFiY2RlQGdpdGVhLmNvbT6JAc4EEwEI
+ADgWIQRo/BkcvP70fnQCv16xVDFkJim4JgUCZOtjdQIbAwULCQgHAgYVCgkICwIE
+FgIDAQIeAQIXgAAKCRCxVDFkJim4Js6+C/9yIjHqcyM88hQAYQUoiPYfgJ0f2NsD
+Ai/XypyDaFbRy9Wqm3oKvMr9L9G5xgOXshjRaRWOpODAwLmtVrJfOV5BhxLEcBcO
+2hDdM3ycp8Gt7+Fx/o0cUjPiiC18hh3K5LRfeE7oYynSJDgjoDNuzIMuyoWuJPNc
++IcE4roND55qyyyC9ObrTLz1GgGm1bXtkHhZ1NdOfQ4q8M48K39Jn7pmnmSX3R74
+CSU6flh/o9AtzGLjU70JUOLFcWnR5D0iEI8mOsdfEHr+p+CvDVG9l4unPhMunT+Q
+OUwV2DEmqo9P+yIert1ucVTDoSf+FrRaKUHg8r1Tt6T4/4GyIeSxG72NImK0h8jz
++bADPZhxuG4UR1Mj8bilqhWgODFPi/5DrDsNMWq1pEvjn6f4pCUx0IDTnPTniOXt
+afXtAD4Rz0rwJWYqgeJFHgjXzaxBiOE1bhS26NPEvyAa0T9Tj3E73ICMESAmVad2
+JqO/mVxkLDGWdpXM7qB8bO2YGMOplrTvWaa5AY0EZOtjdQEMAOwevO46JxBo91RC
+bT7RQ2uz3ZwRKb+P/jIEFST6x8tkCjon31zh6HicBDPNntqXTzStgoHQb7vGhHPV
+4dxAfrOtVyoHwpi1/+x1jjtZoyIzLEz6RNK/Onu2y/tC5JBnSd5QRdHJgzPm20F8
+iNZR37c0Mi24fIH4y01aVLfNeBpRt7lWJ+opo2bM3Rh7jJdMpynKkTcA6o9XP6Ig
+W/dzpOayosclpHhWiJwKV4CovIX/bxawk7sz10Nb4QzcxlWexWnJxNRHIcAkZ9KT
+XTBpBkBpHCZqsI3+rQoQn5oQAr9JGWJSd4Fmgw7mFjmIF4bjfa2h/BpCoBqE+/25
+chvWfYkQwrCcyUwD1QYPUBwNvLB+PWb9kYEHD3mLgSSR+fjdG9XdMevu4lT91Gqo
+/6KJzgzClSs7GoQtb+SZ4deUFw1tlmEQS/BGhbtTb/1566iDidGV5EnSmL/E4/3C
+bGQqNog8gremF0G0SlWTjD9RMBY13IgisWCC6R4CdkXIYnCWbwARAQABiQG2BBgB
+CAAgFiEEaPwZHLz+9H50Ar9esVQxZCYpuCYFAmTrY3UCGwwACgkQsVQxZCYpuCb1
+AAv/dI5YtGxBXaHAMj+lOLmZi5w4t0M7Zafa8tNnWrBwj4KixiXEt52i5YKxuaVD
+3+/cMqidSDp0M5Cxx0wcmnmg+mdFFcowtXIXuk1TGTcHcOCPoXgF6gfoGimNNE1A
+w1+EnC4/TbjMCKEM7b2QZ7/CgkBxZJWbScN4Jtawory9LEQqo0/epYJwf+79GHIJ
+rpODAPiPJEMKmlej23KyoFuusOi17C0vHCf3GZNj4F2So3LOrcs51qTlOum2MdL5
+oTdqffatzs6p4u5bHBxyRugQlQggTRSK+TXLdxnFXr9ukXjIC2mFir7CCnZHw4e+
+2JwZfaAom0ZX+pLwrReSop4BPPU2YDzt3XCUk0S9kpiOsN7iFWUMCFreIE50DOxt
+9406kSGopYKVaifbDl4MdLXM4v+oucLe7/yOViT/dm4FcIytIR+jzC8MaLQTB23e
+uzm2wOjI1YOwv7Il6PWZyDdU+tyzXcaJ7wSFBeQFZZtqph2TItCeV04HoaKHHc25
+4akc
+=OYIo
+-----END PGP PUBLIC KEY BLOCK-----
+
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+ZZtqph2TItCeV04HoaKHHc254akc
+=PPG4
+-----END PGP PRIVATE KEY BLOCK-----