snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:41:59 +05:00
commit 52ae0c5802
6280 changed files with 624193 additions and 0 deletions

132
routers/common/actions.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"io/fs"
actions_model "gitea.dev/models/actions"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/actions"
"gitea.dev/modules/container"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
context_module "gitea.dev/services/context"
)
func openTaskLogs(ctx context.Context, task *actions_model.ActionTask) (io.ReadSeekCloser, error) {
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
if errors.Is(err, fs.ErrNotExist) {
// convert fs err to our own error type so that the API can return a 404 instead of a 500
return nil, util.NewNotExistErrorf("unable to open task logs: %s", task.LogFilename)
}
return reader, err
}
func DownloadActionsRunJobLogsWithID(ctx *context_module.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error {
job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID)
if err != nil {
return err
}
return DownloadActionsRunJobLogs(ctx, ctxRepo, job)
}
// DownloadActionsRunAllJobLogs assumes the run was already resolved against the requesting repository.
func DownloadActionsRunAllJobLogs(ctx *context_module.Base, run *actions_model.ActionRun) error {
runJobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run)
if err != nil {
return fmt.Errorf("GetLatestAttemptJobsByRun: %w", err)
}
tasks, err := actions_model.GetTasksMapByIDs(ctx, container.FilterSlice(runJobs, func(job *actions_model.ActionRunJob) (int64, bool) {
taskID := job.EffectiveTaskID()
return taskID, taskID != 0
}))
if err != nil {
return fmt.Errorf("GetTasksMapByIDs: %w", err)
}
// Delay the headers until the first log opens, afterwards a failure can no longer reach
// the client, so the remaining entries are best-effort.
var zipWriter *zip.Writer
for _, job := range runJobs {
task := tasks[job.EffectiveTaskID()]
if task == nil || task.LogExpired {
continue
}
reader, err := openTaskLogs(ctx, task)
if err != nil {
log.Error("Failed to open logs of job %d: %v", job.ID, err)
continue
}
if zipWriter == nil {
ctx.SetServeHeaders(context_module.ServeHeaderOptions{
Filename: util.FileNameJoinFields(util.PathBaseStem(run.WorkflowID), "run", run.ID, "logs", ".zip"),
ContentType: "application/zip",
ContentDisposition: httplib.ContentDispositionAttachment,
})
zipWriter = zip.NewWriter(ctx.Resp)
}
zipFile, err := zipWriter.Create(util.FileNameJoinFields(util.PathBaseStem(run.WorkflowID), job.Name, task.ID, ".log"))
if err == nil {
_, err = io.Copy(zipFile, reader)
}
reader.Close()
if err != nil {
log.Error("Failed to add logs of job %d to zip: %v", job.ID, err)
}
}
if zipWriter == nil {
return util.NewNotExistErrorf("logs not found")
}
if err := zipWriter.Close(); err != nil {
log.Error("Failed to finalize logs zip of run %d: %v", run.ID, err)
}
return nil
}
func DownloadActionsRunJobLogs(ctx *context_module.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error {
if curJob.RepoID != ctxRepo.ID {
return util.NewNotExistErrorf("job not found")
}
if err := curJob.LoadRun(ctx); err != nil {
return fmt.Errorf("LoadRun: %w", err)
}
taskID := curJob.EffectiveTaskID()
if taskID == 0 {
return util.NewNotExistErrorf("job not started")
}
task, err := actions_model.GetTaskByID(ctx, taskID)
if err != nil {
return fmt.Errorf("GetTaskByID: %w", err)
}
if task.LogExpired {
return util.NewNotExistErrorf("logs have been cleaned up")
}
reader, err := openTaskLogs(ctx, task)
if err != nil {
return err
}
defer reader.Close()
ctx.ServeContent(reader, context_module.ServeHeaderOptions{
Filename: util.FileNameJoinFields(util.PathBaseStem(curJob.Run.WorkflowID), curJob.Name, task.ID, ".log"),
ContentLength: &task.LogSize,
ContentType: "text/plain; charset=utf-8",
ContentDisposition: httplib.ContentDispositionAttachment,
})
return nil
}

26
routers/common/audit.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/httplib"
"gitea.dev/modules/reqctx"
audit_service "gitea.dev/services/audit"
)
// AuditOrigin publishes the origin and the client address of the request, so
// audit events recorded while serving it are attributed to it.
func AuditOrigin(origin audit_model.Origin) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if store := reqctx.GetRequestDataStore(req.Context()); store != nil {
audit_service.SetRequestInfo(store, origin, httplib.RemoteHost(req))
}
next.ServeHTTP(resp, req)
})
}
}

58
routers/common/auth.go Normal file
View File

@@ -0,0 +1,58 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/web/middleware"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/context"
)
type AuthResult struct {
Doer *user_model.User
IsBasicAuth bool
}
func AuthShared(ctx *context.Base, sessionStore auth_service.SessionStore, authMethod auth_service.Method) (ar AuthResult, err error) {
ar.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, sessionStore)
if err != nil {
return ar, err
}
if ar.Doer != nil {
if ctx.Locale.Language() != ar.Doer.Language {
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
}
ar.IsBasicAuth = ctx.Data["AuthedMethod"] == auth_service.BasicMethodName
ctx.Data["IsSigned"] = true
ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer
ctx.Data["SignedUserID"] = ar.Doer.ID
ctx.Data["IsAdmin"] = ar.Doer.IsAdmin
if sessionStore != nil {
if uid := auth_service.ImpersonatorUserID(sessionStore); uid != 0 {
impersonator, err := user_model.GetUserByID(ctx, uid)
if err != nil {
// the session stays usable, but audit events must not silently lose the admin behind it
log.Error("Unable to resolve impersonator %d: %v", uid, err)
} else {
ctx.Data[middleware.ContextDataKeyImpersonator] = impersonator
}
}
}
} else {
ctx.Data["SignedUserID"] = int64(0)
}
return ar, nil
}
// VerifyOptions contains required or check options
type VerifyOptions struct {
SignInRequired bool
SignOutRequired bool
AdminRequired bool
DisableCrossOriginProtection bool
}

View File

@@ -0,0 +1,89 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"strings"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/web/middleware"
"gitea.dev/modules/web/routing"
"github.com/go-chi/chi/v5"
)
func BlockExpensive() func(next http.Handler) http.Handler {
if !setting.Service.BlockAnonymousAccessExpensive {
return nil
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ret := determineRequestPriority(reqctx.FromContext(req.Context()))
if !ret.SignedIn {
if ret.Expensive || ret.LongPolling {
http.Redirect(w, req, middleware.RedirectLinkUserLogin(req), http.StatusSeeOther)
return
}
}
next.ServeHTTP(w, req)
})
}
}
func isRoutePathExpensive(routePattern string) bool {
if strings.HasPrefix(routePattern, "/user/") || strings.HasPrefix(routePattern, "/login/") {
return false
}
expensivePaths := []string{
// code related
"/{username}/{reponame}/archive/",
"/{username}/{reponame}/blame/",
"/{username}/{reponame}/commit/",
"/{username}/{reponame}/commits/",
"/{username}/{reponame}/compare/",
"/{username}/{reponame}/graph",
"/{username}/{reponame}/media/",
"/{username}/{reponame}/raw/",
"/{username}/{reponame}/rss/branch/",
"/{username}/{reponame}/src/",
// issue & PR related (no trailing slash)
"/{username}/{reponame}/issues",
"/{username}/{reponame}/{type:issues}",
"/{username}/{reponame}/pulls",
"/{username}/{reponame}/{type:pulls}",
// wiki
"/{username}/{reponame}/wiki/",
// activity
"/{username}/{reponame}/activity/",
}
for _, path := range expensivePaths {
if strings.HasPrefix(routePattern, path) {
return true
}
}
return false
}
func determineRequestPriority(reqCtx reqctx.RequestContext) (ret struct {
SignedIn bool
Expensive bool
LongPolling bool
},
) {
chiRoutePath := chi.RouteContext(reqCtx).RoutePattern()
if _, ok := reqCtx.GetData()[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
ret.SignedIn = true
} else {
ret.Expensive = isRoutePathExpensive(chiRoutePath)
ret.LongPolling = routing.GetRequestRecordInfo(reqCtx).IsLongPolling
}
return ret
}

View File

@@ -0,0 +1,28 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBlockExpensive(t *testing.T) {
cases := []struct {
expensive bool
routePath string
}{
{false, "/user/xxx"},
{false, "/login/xxx"},
{true, "/{username}/{reponame}/archive/xxx"},
{true, "/{username}/{reponame}/graph"},
{true, "/{username}/{reponame}/src/xxx"},
{true, "/{username}/{reponame}/wiki/xxx"},
{true, "/{username}/{reponame}/activity/xxx"},
}
for _, c := range cases {
assert.Equal(t, c.expensive, isRoutePathExpensive(c.routePath), "routePath: %s", c.routePath)
}
}

View File

@@ -0,0 +1,33 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"gitea.dev/modules/indexer"
code_indexer "gitea.dev/modules/indexer/code"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
func PrepareCodeSearch(ctx *context.Context) (ret struct {
Keyword string
Language string
SearchMode indexer.SearchModeType
},
) {
ret.Language = ctx.FormTrim("l")
ret.Keyword = ctx.FormTrim("q")
ret.SearchMode = indexer.SearchModeType(ctx.FormTrim("search_mode"))
ctx.Data["Keyword"] = ret.Keyword
ctx.Data["Language"] = ret.Language
ctx.Data["SelectedSearchMode"] = string(ret.SearchMode)
if setting.Indexer.RepoIndexerEnabled {
ctx.Data["SearchModes"] = code_indexer.SupportedSearchModes()
} else {
ctx.Data["SearchModes"] = indexer.GitGrepSupportedSearchModes()
}
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
return ret
}

221
routers/common/compare.go Normal file
View File

@@ -0,0 +1,221 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"regexp"
"strings"
"sync"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/util"
)
type CompareRouterReq struct {
BaseOriRef string
BaseOriRefSuffix string
CompareSeparator string
HeadOwner string
HeadRepoName string
HeadOriRef string
HeadOriRefSuffix string
}
func (cr *CompareRouterReq) DirectComparison() bool {
// FIXME: the design of "DirectComparison" is wrong, it loses the information of `^`
// To correctly handle the comparison, developers should use `ci.CompareSeparator` directly, all "DirectComparison" related code should be rewritten.
return cr.CompareSeparator == ".."
}
func parseHead(head string) (headOwnerName, headRepoName, headRef string) {
paths := strings.SplitN(head, ":", 2)
if len(paths) == 1 {
return "", "", paths[0]
}
ownerRepo := strings.SplitN(paths[0], "/", 2)
if len(ownerRepo) == 1 {
return paths[0], "", paths[1]
}
return ownerRepo[0], ownerRepo[1], paths[1]
}
// ParseCompareRouterParam Get compare information from the router parameter.
// A full compare url is of the form:
//
// 0. /{:baseOwner}/{:baseRepoName}/compare
// 1. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headBranch}
// 2. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}:{:headBranch}
// 3. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}/{:headRepoName}:{:headBranch}
// 4. /{:baseOwner}/{:baseRepoName}/compare/{:headBranch}
// 5. /{:baseOwner}/{:baseRepoName}/compare/{:headOwner}:{:headBranch}
// 6. /{:baseOwner}/{:baseRepoName}/compare/{:headOwner}/{:headRepoName}:{:headBranch}
//
// Here we obtain the infoPath "{:baseBranch}...[{:headOwner}/{:headRepoName}:]{:headBranch}" as ctx.PathParam("*")
// with the :baseRepo in ctx.Repo.
//
// Note: Generally :headRepoName is not provided here - we are only passed :headOwner.
//
// How do we determine the :headRepo?
//
// 1. If :headOwner is not set then the :headRepo = :baseRepo
// 2. If :headOwner is set - then look for the fork of :baseRepo owned by :headOwner
// 3. But... :baseRepo could be a fork of :headOwner's repo - so check that
// 4. Now, :baseRepo and :headRepos could be forks of the same repo - so check that
//
// format: <base branch>...[<head repo>:]<head branch>
// base<-head: master...head:feature
// same repo: master...feature
func ParseCompareRouterParam(routerParam string) *CompareRouterReq {
if routerParam == "" {
return &CompareRouterReq{}
}
sep := "..."
basePart, headPart, ok := strings.Cut(routerParam, sep)
if !ok {
sep = ".."
basePart, headPart, ok = strings.Cut(routerParam, sep)
if !ok {
headOwnerName, headRepoName, headOriRef := parseHead(routerParam)
headOriRef, headOriRefSuffix := git.ParseRefSuffix(headOriRef)
return &CompareRouterReq{
HeadOriRef: headOriRef,
HeadOriRefSuffix: headOriRefSuffix,
HeadOwner: headOwnerName,
HeadRepoName: headRepoName,
CompareSeparator: "...",
}
}
}
ci := &CompareRouterReq{CompareSeparator: sep}
ci.BaseOriRef, ci.BaseOriRefSuffix = git.ParseRefSuffix(basePart)
ci.HeadOwner, ci.HeadRepoName, ci.HeadOriRef = parseHead(headPart)
ci.HeadOriRef, ci.HeadOriRefSuffix = git.ParseRefSuffix(ci.HeadOriRef)
return ci
}
// validRefSuffix matches only ^/~ ancestry navigation. The ^{...}, @{...} and :path forms address
// other objects (trees, blobs) or reflog/upstream state that compare does not resolve, so they are rejected.
var validRefSuffix = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`^(?:[~^][0-9]*)+$`)
})
// ResolveRefWithSuffix resolves oriRef plus an optional revision suffix (^, ~N) to a RefName.
// A nil error guarantees a usable RefName: an unsupported suffix yields an invalid-argument error
// and an unresolvable ref yields a not-found error.
func ResolveRefWithSuffix(ctx context.Context, gitRepo *git.Repository, oriRef, refSuffix string) (git.RefName, error) {
if refSuffix == "" {
if refName := gitRepo.UnstableGuessRefByShortName(ctx, oriRef); refName != "" {
return refName, nil
}
return "", util.NewNotExistErrorf("ref %q does not exist", oriRef)
}
if !validRefSuffix().MatchString(refSuffix) {
return "", util.NewInvalidArgumentErrorf("unsupported ref suffix %q", refSuffix)
}
commit, err := gitRepo.GetCommit(ctx, oriRef+refSuffix)
if err != nil {
return "", util.NewNotExistErrorf("ref %q does not exist", oriRef+refSuffix)
}
return git.RefNameFromCommit(commit.ID.String()), nil
}
// maxForkTraverseLevel defines the maximum levels to traverse when searching for the head repository.
const maxForkTraverseLevel = 10
// FindHeadRepo tries to find the head repository based on the base repository and head user ID.
func FindHeadRepo(ctx context.Context, baseRepo *repo_model.Repository, headUserID int64) (*repo_model.Repository, error) {
if baseRepo.IsFork {
curRepo := baseRepo
for curRepo.OwnerID != headUserID { // We assume the fork deepth is not too deep.
if err := curRepo.GetBaseRepo(ctx); err != nil {
return nil, err
}
if curRepo.BaseRepo == nil {
return findHeadRepoFromRootBase(ctx, curRepo, headUserID, maxForkTraverseLevel)
}
curRepo = curRepo.BaseRepo
}
return curRepo, nil
}
return findHeadRepoFromRootBase(ctx, baseRepo, headUserID, maxForkTraverseLevel)
}
func GetHeadOwnerAndRepo(ctx context.Context, baseRepo *repo_model.Repository, compareReq *CompareRouterReq) (headOwner *user_model.User, headRepo *repo_model.Repository, err error) {
if compareReq.HeadOwner == "" {
if compareReq.HeadRepoName != "" { // unsupported syntax
return nil, nil, util.ErrorWrap(util.ErrInvalidArgument, "head owner must be specified when head repo name is given")
}
return baseRepo.Owner, baseRepo, nil
}
if compareReq.HeadOwner == baseRepo.Owner.Name {
headOwner = baseRepo.Owner
} else {
headOwner, err = user_model.GetUserByName(ctx, compareReq.HeadOwner)
if err != nil {
return nil, nil, err
}
}
if compareReq.HeadRepoName == "" {
if headOwner.ID == baseRepo.OwnerID {
headRepo = baseRepo
} else {
headRepo, err = FindHeadRepo(ctx, baseRepo, headOwner.ID)
if err != nil {
return nil, nil, err
}
if headRepo == nil {
return nil, nil, util.ErrorWrap(util.ErrInvalidArgument, "the user %s does not have a fork of the base repository", headOwner.Name)
}
}
} else {
if compareReq.HeadOwner == baseRepo.Owner.Name && compareReq.HeadRepoName == baseRepo.Name {
headRepo = baseRepo
} else {
headRepo, err = repo_model.GetRepositoryByName(ctx, headOwner.ID, compareReq.HeadRepoName)
if err != nil {
return nil, nil, err
}
}
}
return headOwner, headRepo, nil
}
func findHeadRepoFromRootBase(ctx context.Context, baseRepo *repo_model.Repository, headUserID int64, traverseLevel int) (*repo_model.Repository, error) {
if traverseLevel == 0 {
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
}
// test if we are lucky
repo, err := repo_model.GetUserFork(ctx, baseRepo.ID, headUserID)
if err != nil {
return nil, err
}
if repo != nil {
return repo, nil
}
firstLevelForkedRepos, err := repo_model.GetRepositoriesByForkID(ctx, baseRepo.ID)
if err != nil {
return nil, err
}
for _, repo := range firstLevelForkedRepos {
forked, err := findHeadRepoFromRootBase(ctx, repo, headUserID, traverseLevel-1)
if err != nil {
return nil, err
}
if forked != nil {
return forked, nil
}
}
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
}

View File

@@ -0,0 +1,154 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
func TestCompareRouterReq(t *testing.T) {
cases := []struct {
input string
CompareRouterReq *CompareRouterReq
}{
{
input: "",
CompareRouterReq: &CompareRouterReq{},
},
{
input: "v1.0...v1.1",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "v1.0",
CompareSeparator: "...",
HeadOriRef: "v1.1",
},
},
{
input: "main..develop",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
CompareSeparator: "..",
HeadOriRef: "develop",
},
},
{
input: "main^...develop",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
BaseOriRefSuffix: "^",
CompareSeparator: "...",
HeadOriRef: "develop",
},
},
{
input: "main^^^^^...develop",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
BaseOriRefSuffix: "^^^^^",
CompareSeparator: "...",
HeadOriRef: "develop",
},
},
{
input: "develop",
CompareRouterReq: &CompareRouterReq{
CompareSeparator: "...",
HeadOriRef: "develop",
},
},
{
input: "teabot:feature1",
CompareRouterReq: &CompareRouterReq{
CompareSeparator: "...",
HeadOwner: "teabot",
HeadOriRef: "feature1",
},
},
{
input: "lunny/forked_repo:develop",
CompareRouterReq: &CompareRouterReq{
CompareSeparator: "...",
HeadOwner: "lunny",
HeadRepoName: "forked_repo",
HeadOriRef: "develop",
},
},
{
input: "main...lunny/forked_repo:develop",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
CompareSeparator: "...",
HeadOwner: "lunny",
HeadRepoName: "forked_repo",
HeadOriRef: "develop",
},
},
{
input: "main^...lunny/forked_repo:develop",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
BaseOriRefSuffix: "^",
CompareSeparator: "...",
HeadOwner: "lunny",
HeadRepoName: "forked_repo",
HeadOriRef: "develop",
},
},
{
input: "main...develop^",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
CompareSeparator: "...",
HeadOriRef: "develop",
HeadOriRefSuffix: "^",
},
},
{
input: "main~2...develop",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
BaseOriRefSuffix: "~2",
CompareSeparator: "...",
HeadOriRef: "develop",
},
},
{
input: "main...lunny/forked_repo:develop~3",
CompareRouterReq: &CompareRouterReq{
BaseOriRef: "main",
CompareSeparator: "...",
HeadOwner: "lunny",
HeadRepoName: "forked_repo",
HeadOriRef: "develop",
HeadOriRefSuffix: "~3",
},
},
{
input: "develop^",
CompareRouterReq: &CompareRouterReq{
CompareSeparator: "...",
HeadOriRef: "develop",
HeadOriRefSuffix: "^",
},
},
}
for _, c := range cases {
assert.Equal(t, c.CompareRouterReq, ParseCompareRouterParam(c.input), "input: %s", c.input)
}
}
func TestResolveRefWithSuffix(t *testing.T) {
// The ^{...}, @{...} and :path forms address non-commit objects or reflog state, so they are
// rejected before any repository access and a nil repo is fine here.
for _, refSuffix := range []string{"^{/Add}", "^{commit}", "@{upstream}", "~1:path"} {
ref, err := ResolveRefWithSuffix(t.Context(), nil, "branch", refSuffix)
assert.ErrorIs(t, err, util.ErrInvalidArgument, "suffix %q", refSuffix)
assert.Empty(t, ref, "suffix %q", refSuffix)
}
}

58
routers/common/db.go Normal file
View File

@@ -0,0 +1,58 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"time"
"gitea.dev/modelmigration"
"gitea.dev/models/db"
system_model "gitea.dev/models/system"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/setting/config"
"gitea.dev/services/versioned_migration"
)
// InitDBEngine In case of problems connecting to DB, retry connection. Eg, PGSQL in Docker Container on Synology
func InitDBEngine(ctx context.Context) (err error) {
log.Info("Beginning ORM engine initialization.")
for i := 0; i < setting.Database.DBConnectRetries; i++ {
select {
case <-ctx.Done():
return errors.New("Aborted due to shutdown:\nin retry ORM engine initialization")
default:
}
log.Info("ORM engine initialization attempt #%d/%d...", i+1, setting.Database.DBConnectRetries)
if err = db.InitEngineWithMigration(ctx, migrateWithSetting); err == nil {
break
} else if i == setting.Database.DBConnectRetries-1 {
return err
}
log.Error("ORM engine initialization attempt #%d/%d failed. Error: %v", i+1, setting.Database.DBConnectRetries, err)
log.Info("Backing off for %d seconds", int64(setting.Database.DBConnectBackoff/time.Second))
time.Sleep(setting.Database.DBConnectBackoff)
}
config.SetDynGetter(system_model.NewDatabaseDynKeyGetter())
return nil
}
func migrateWithSetting(ctx context.Context, x db.EngineMigration) error {
if setting.Database.AutoMigration {
return versioned_migration.Migrate(ctx, x)
}
if current, err := modelmigration.GetCurrentDBVersion(x); err != nil {
return err
} else if current < 0 {
// execute migrations when the database isn't initialized even if AutoMigration is false
return versioned_migration.Migrate(ctx, x)
} else if expected := modelmigration.ExpectedDBVersion(); current != expected {
log.Fatal(`"database.AUTO_MIGRATION" is disabled, but current database version %d is not equal to the expected version %d.`+
`You can set "database.AUTO_MIGRATION" to true or migrate manually by running "gitea [--config /path/to/app.ini] migrate"`, current, expected)
}
return nil
}

View File

@@ -0,0 +1,31 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"time"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
)
func ParseDeadlineDateToEndOfDay(date string) (timeutil.TimeStamp, error) {
if date == "" {
return 0, nil
}
deadline, err := time.ParseInLocation("2006-01-02", date, setting.DefaultUILocation)
if err != nil {
return 0, err
}
deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location())
return timeutil.TimeStamp(deadline.Unix()), nil
}
func ParseAPIDeadlineToEndOfDay(t *time.Time) (timeutil.TimeStamp, error) {
if t == nil || t.IsZero() || t.Unix() == 0 {
return 0, nil
}
deadline := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation)
return timeutil.TimeStamp(deadline.Unix()), nil
}

76
routers/common/errpage.go Normal file
View File

@@ -0,0 +1,76 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"fmt"
"io"
"net/http"
"strings"
user_model "gitea.dev/models/user"
"gitea.dev/modules/httpcache"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web/middleware"
"gitea.dev/modules/web/routing"
"gitea.dev/services/context"
)
const tplStatus500 templates.TplName = "status/500"
func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode int, tmpl templates.TplName, ctxData map[string]any, plainMsg string) {
acceptsHTML := false
for _, part := range req.Header["Accept"] {
if strings.Contains(part, "text/html") {
acceptsHTML = true
break
}
}
httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{})
tmplCtx := context.NewTemplateContextForWeb(reqctx.FromContext(req.Context()), req, middleware.Locale(w, req))
w.WriteHeader(respCode)
outBuf := &bytes.Buffer{}
if acceptsHTML {
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
if err != nil {
log.Error("Failed to render error page template %s: %v", tmpl, err)
_, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker"))
return
}
} else {
outBuf.WriteString(plainMsg)
}
_, _ = io.Copy(w, outBuf)
}
// renderPanicErrorPage renders a 500 page with the recovered panic value, it handles the stack trace, and it never panics
func renderPanicErrorPage(w http.ResponseWriter, req *http.Request, recovered any) {
combinedErr := fmt.Errorf("%v\n%s", recovered, log.Stack(2))
log.Error("PANIC: %v", combinedErr)
defer func() {
if err := recover(); err != nil {
log.Error("Panic occurs again when rendering error page: %v. Stack:\n%s", combinedErr, log.Stack(2))
}
}()
routing.UpdatePanicError(req.Context(), combinedErr)
plainMsg := "Internal Server Error"
ctxData := middleware.GetContextData(req.Context())
// This recovery handler could be called without Gitea's web context, so we shouldn't touch that context too much.
// Otherwise, the 500-page may cause new panics, eg: cache.GetContextWithData, it makes the developer&users couldn't find the original panic.
user, _ := ctxData[middleware.ContextDataKeySignedUser].(*user_model.User)
if !setting.IsProd || (user != nil && user.IsAdmin) {
plainMsg = "PANIC: " + combinedErr.Error()
ctxData["ErrorMsg"] = plainMsg
}
renderServerErrorPage(w, req, http.StatusInternalServerError, tplStatus500, ctxData, plainMsg)
}

View File

@@ -0,0 +1,47 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gitea.dev/models/unittest"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestRenderPanicErrorPage(t *testing.T) {
t.Run("HTML", func(t *testing.T) {
w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}}
req = req.WithContext(reqctx.NewRequestContextForTest(t))
renderPanicErrorPage(w, req, errors.New("fake panic error (for test only)"))
respContent := w.Body.String()
assert.Contains(t, respContent, `class="page-content status-page-500"`)
assert.Contains(t, respContent, `</html>`)
assert.Contains(t, respContent, `lang="en-US"`) // make sure the locale work
// the 500 page doesn't have normal pages footer, it makes it easier to distinguish a normal page and a failed page.
// especially when a sub-template causes page error, the HTTP response code is still 200,
// the different "footer" is the only way to know whether a page is fully rendered without error.
assert.False(t, test.IsNormalPageCompleted(respContent))
})
t.Run("Plain", func(t *testing.T) {
w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}}
req = req.WithContext(reqctx.NewRequestContextForTest(t))
renderServiceUnavailable(w, req)
assert.Equal(t, "Service Unavailable", w.Body.String())
})
}
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

View File

@@ -0,0 +1,87 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
)
func ParseIssueFilterStateIsClosed(state string) optional.Option[bool] {
switch state {
case "all":
return optional.None[bool]()
case "closed":
return optional.Some(true)
case "", "open":
return optional.Some(false)
default:
return optional.Some(false) // unknown state, undefined behavior
}
}
func ParseIssueFilterTypeIsPull(typ string) optional.Option[bool] {
return optional.FromMapLookup(map[string]bool{"pulls": true, "issues": false}, typ)
}
type SearchIssuesRepoIDsOptions struct {
Doer *user_model.User
PublicOnly bool
OwnerName string
TeamName string
}
// SearchIssuesRepoIDs resolves the repository filter of an issue search. allPublic makes the indexer
// match everything its own is_public covers (modules/indexer/issues/util.go), so repoIDs omits those.
func SearchIssuesRepoIDs(ctx context.Context, opts SearchIssuesRepoIDsOptions) (repoIDs []int64, allPublic bool, err error) {
searchOpts := repo_model.SearchRepoOptions{
Private: opts.Doer != nil,
Collaborate: optional.None[bool](),
Actor: opts.Doer,
}
searchOpts.ApplyPublicOnly(opts.PublicOnly)
if opts.OwnerName != "" {
owner, err := user_model.GetUserByName(ctx, opts.OwnerName)
if err != nil {
return nil, false, err
}
searchOpts.OwnerID = owner.ID
searchOpts.Collaborate = optional.Some(false)
}
if opts.TeamName != "" {
if opts.OwnerName == "" {
return nil, false, util.NewInvalidArgumentErrorf("owner organisation is required for filtering on team")
}
team, err := organization.GetTeam(ctx, searchOpts.OwnerID, opts.TeamName)
if err != nil {
return nil, false, err
}
searchOpts.TeamID = team.ID
}
// SearchRepoOptions.AllPublic and AllLimited only apply under an owner filter, so the indexer covers them
allPublic = opts.OwnerName == ""
cond := repo_model.SearchRepositoryCondition(searchOpts)
if allPublic {
if !searchOpts.Private {
return []int64{0}, allPublic, nil // sees nothing beyond is_public, so skip the query
}
cond = cond.And(repo_model.NotPublicRepoUnderPublicOwnerCond()) // enumerating them scales with the instance
}
repoIDs, err = repo_model.SearchRepositoryIDsByCondition(ctx, cond)
if err != nil {
return nil, false, err
}
if len(repoIDs) == 0 {
// no repos found, don't let the indexer return all repos
repoIDs = []int64{0}
}
return repoIDs, allPublic, nil
}

View File

@@ -0,0 +1,93 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSearchIssuesRepoIDs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// the indexer's is_public covers repo 1 (public under a public owner) but misses repo 38 (public
// under a limited org) and repo 40 (public under a private org)
cases := []struct {
name string
doerID int64
opts SearchIssuesRepoIDsOptions
allPublic bool
want []int64
wantErr error
}{
{
name: "site admin", // admins skip the accessible repository condition entirely
doerID: 1,
allPublic: true,
want: []int64{2, 38, 40},
},
{
name: "regular user",
doerID: 2,
allPublic: true,
want: []int64{2, 38},
},
{
name: "private org member",
doerID: 5,
allPublic: true,
want: []int64{38, 40},
},
{
name: "anonymous",
allPublic: true,
want: []int64{0}, // the placeholder keeps the indexer off "every repository"
},
{
name: "public-only token",
doerID: 2,
opts: SearchIssuesRepoIDsOptions{PublicOnly: true},
allPublic: true,
want: []int64{0},
},
{
name: "owner filter", // turns allPublic off, so public repos must still be enumerated
doerID: 2,
opts: SearchIssuesRepoIDsOptions{OwnerName: "user2"},
want: []int64{1, 2},
},
{
name: "team without owner",
opts: SearchIssuesRepoIDsOptions{TeamName: "team1"},
wantErr: util.ErrInvalidArgument,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
opts := tc.opts
if tc.doerID != 0 {
opts.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: tc.doerID})
}
repoIDs, allPublic, err := SearchIssuesRepoIDs(t.Context(), opts)
if tc.wantErr != nil {
assert.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tc.allPublic, allPublic)
assert.Subset(t, repoIDs, tc.want)
if allPublic {
assert.NotContains(t, repoIDs, int64(1), "already matched by the indexer's is_public")
}
})
}
}

31
routers/common/lfs.go Normal file
View File

@@ -0,0 +1,31 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"gitea.dev/modules/web"
"gitea.dev/services/lfs"
)
const RouterMockPointCommonLFS = "common-lfs"
func AddOwnerRepoGitLFSRoutes(m *web.Router, middlewares ...any) {
// shared by web and internal routers
m.Group("/{username}/{reponame}/info/lfs", func() {
m.Post("/objects/batch", lfs.CheckAcceptMediaType, lfs.BatchHandler)
m.Put("/objects/{oid}/{size}", lfs.UploadHandler)
m.Get("/objects/{oid}/{filename}", lfs.DownloadHandler)
m.Get("/objects/{oid}", lfs.DownloadHandler)
m.Post("/verify", lfs.CheckAcceptMediaType, lfs.VerifyHandler)
m.Group("/locks", func() {
m.Get("/", lfs.GetListLockHandler)
m.Post("/", lfs.PostLockHandler)
m.Post("/verify", lfs.VerifyLockHandler)
m.Post("/{lid}/unlock", lfs.UnLockHandler)
}, lfs.CheckAcceptMediaType)
m.Any("/*", http.NotFound)
}, append([]any{web.RouterMockPoint(RouterMockPointCommonLFS)}, middlewares...)...)
}

View File

@@ -0,0 +1,53 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"strings"
"gitea.dev/modules/container"
"gitea.dev/modules/setting"
)
func MaintenanceModeHandler() func(h http.Handler) http.Handler {
allowedPrefixes := []string{
"/.well-known/",
"/assets/",
"/avatars/",
// admin: "/-/admin"
// general-purpose URLs: "/-/fetch-redirect", "/-/markup", etc.
"/-/",
// internal APIs
"/api/internal/",
// user login (for admin to login): "/user/login", "/user/logout", "/catpcha/..."
"/user/",
"/captcha/",
}
allowedPaths := container.SetOf(
"/api/healthz",
)
isMaintenanceModeAllowedRequest := func(req *http.Request) bool {
for _, prefix := range allowedPrefixes {
if strings.HasPrefix(req.URL.Path, prefix) {
return true
}
}
return allowedPaths.Contains(req.URL.Path)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
maintenanceMode := setting.Config().Instance.MaintenanceMode.Value(req.Context())
if maintenanceMode.IsActive() && !isMaintenanceModeAllowedRequest(req) {
renderServiceUnavailable(resp, req)
return
}
next.ServeHTTP(resp, req)
})
}
}

110
routers/common/markup.go Normal file
View File

@@ -0,0 +1,110 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"net/http"
"path"
"strings"
"gitea.dev/models/renderhelper"
"gitea.dev/models/repo"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
// RenderMarkup renders markup text for the /markup and /markdown endpoints
func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, urlPathContext, filePath string) {
// urlPathContext format is "/subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}"
// filePath is the path of the file to render if the end user is trying to preview a repo file (mode == "file")
// filePath will be used as RenderContext.RelativePath
// TODO: MARKUP-RENDER-CONTEXT: this logic is unnecessarily complicated.
// Ideally: the "file path" should not appear in the "url path context", but it needs a lot of refactoring to achieve that
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
ctx.SetHeaderContentSecurityPolicyGeneral()
if mode == "" || mode == "markdown" {
// raw Markdown doesn't do any special handling
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, urlPathContext).WithUseAbsoluteLink(true).
WithMarkupType(markdown.MarkupName)
if err := markdown.RenderRaw(rctx, strings.NewReader(text), ctx.Resp); err != nil {
log.Error("RenderMarkupRaw: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "failed to render raw markup")
}
return
}
// Ideally, this handler should be called with RepoAssignment and get the related repo from context "/owner/repo/markup"
// then render could use the repo to do various things (the permission check has passed)
//
// However, this handler is also exposed as "/markup" without any repo context,
// then since there is no permission check, so we can't use the repo from "context" parameter,
// in this case, only the "path" information could be used which doesn't cause security problems.
var repoModel *repo.Repository
if ctxRepo != nil {
repoModel = ctxRepo.Repository
}
var repoOwnerName, repoName, refPath, treePath string
repoLinkPath := strings.TrimPrefix(urlPathContext, setting.AppSubURL+"/")
fields := strings.SplitN(repoLinkPath, "/", 5)
if len(fields) == 5 && fields[2] == "src" && (fields[3] == "branch" || fields[3] == "commit" || fields[3] == "tag") {
// absolute base prefix is something like "https://host/subpath/{user}/{repo}"
repoOwnerName, repoName = fields[0], fields[1]
treePath = path.Dir(filePath) // it is "doc" if filePath is "doc/CHANGE.md"
refPath = strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc"
refPath = strings.TrimSuffix(refPath, "/"+treePath) // now we get the correct branch path: "branch/features/feat-12"
refPath = util.PathEscapeSegments(refPath)
} else if fields = strings.SplitN(repoLinkPath, "/", 3); len(fields) == 2 {
repoOwnerName, repoName = fields[0], fields[1]
}
var rctx *markup.RenderContext
switch mode {
case "gfm": // legacy mode
rctx = renderhelper.NewRenderContextRepoFile(ctx, repoModel, renderhelper.RepoFileOptions{
DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName,
CurrentRefSubURL: refPath, CurrentTreePath: treePath,
})
rctx = rctx.WithMarkupType(markdown.MarkupName)
case "comment":
rctx = renderhelper.NewRenderContextRepoComment(ctx, repoModel, renderhelper.RepoCommentOptions{
DeprecatedOwnerName: repoOwnerName,
DeprecatedRepoName: repoName,
FootnoteContextID: "preview",
})
rctx = rctx.WithMarkupType(markdown.MarkupName)
case "wiki":
rctx = renderhelper.NewRenderContextRepoWiki(ctx, repoModel, renderhelper.RepoWikiOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName})
rctx = rctx.WithMarkupType(markdown.MarkupName)
case "file":
rctx = renderhelper.NewRenderContextRepoFile(ctx, repoModel, renderhelper.RepoFileOptions{
DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName,
CurrentRefSubURL: refPath, CurrentTreePath: treePath,
})
rctx = rctx.WithMarkupType("").WithRelativePath(filePath) // render the repo file content by its extension
default:
ctx.HTTPError(http.StatusUnprocessableEntity, "unsupported render mode: "+mode)
return
}
rctx = rctx.WithUseAbsoluteLink(true)
if err := markup.Render(rctx, strings.NewReader(text), ctx.Resp); err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.HTTPError(http.StatusUnprocessableEntity, err.Error())
} else {
log.Error("RenderMarkup: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "failed to render markup")
}
return
}
}

View File

@@ -0,0 +1,147 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"net/http"
"strings"
"gitea.dev/modules/cache"
"gitea.dev/modules/gtprof"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/public"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/web/routing"
"gitea.dev/services/context"
"gitea.com/go-chi/session"
"github.com/chi-middleware/proxy"
"github.com/go-chi/chi/v5"
)
// ProtocolMiddlewares returns HTTP protocol related middlewares, and it provides a global panic recovery
func ProtocolMiddlewares() (handlers []any) {
// the order is important
handlers = append(handlers, ChiRoutePathHandler()) // make sure chi has correct paths
handlers = append(handlers, RequestContextHandler()) // prepare the context and panic recovery
handlers = append(handlers, SecurityHeadersHandler())
if setting.ReverseProxyLimit > 0 && len(setting.ReverseProxyTrustedProxies) > 0 {
handlers = append(handlers, ForwardedHeadersHandler(setting.ReverseProxyLimit, setting.ReverseProxyTrustedProxies))
}
handlers = append(handlers, routing.NewRequestInfoHandler())
if setting.IsAccessLogEnabled() {
handlers = append(handlers, context.AccessLogger())
}
if !setting.IsProd {
handlers = append(handlers, public.ViteDevMiddleware)
}
return handlers
}
// SecurityHeadersHandler sets headers globally for every response that leaves Gitea.
func SecurityHeadersHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if setting.Security.XContentTypeOptions != "unset" {
resp.Header().Set("X-Content-Type-Options", setting.Security.XContentTypeOptions)
}
if setting.Security.XFrameOptions != "unset" {
resp.Header().Set("X-Frame-Options", setting.Security.XFrameOptions)
}
next.ServeHTTP(resp, req)
})
}
}
func RequestContextHandler() func(h http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(respOrig http.ResponseWriter, req *http.Request) {
// this response writer might not be the same as the one in context.Base.Resp
// because there might be a "gzip writer" in the middle, so the "written size" here is the compressed size
respWriter := context.WrapResponseWriter(respOrig)
profDesc := fmt.Sprintf("HTTP: %s %s", req.Method, req.RequestURI)
ctx, finished := reqctx.NewRequestContext(req.Context(), profDesc)
defer finished()
ctx, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanHTTP)
req = req.WithContext(ctx)
defer func() {
chiCtx := chi.RouteContext(req.Context())
span.SetAttributeString(gtprof.TraceAttrHTTPRoute, chiCtx.RoutePattern())
span.End()
}()
defer func() {
if recovered := recover(); recovered != nil {
renderPanicErrorPage(respWriter, req, recovered) // it should never panic, and it handles the stack trace internally
}
}()
ctx = cache.WithCacheContext(req.Context())
req = httplib.RequestWithContext(req, reqctx.FromContext(ctx))
next.ServeHTTP(respWriter, req)
})
}
}
func ChiRoutePathHandler() func(h http.Handler) http.Handler {
// make sure chi uses EscapedPath(RawPath) as RoutePath, then "%2f" could be handled correctly
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
chiCtx := chi.RouteContext(req.Context())
if req.URL.RawPath == "" {
chiCtx.RoutePath = req.URL.EscapedPath()
} else {
chiCtx.RoutePath = req.URL.RawPath
}
next.ServeHTTP(resp, req)
})
}
}
func ForwardedHeadersHandler(limit int, trustedProxies []string) func(h http.Handler) http.Handler {
opt := proxy.NewForwardedHeadersOptions().WithForwardLimit(limit).ClearTrustedProxies()
for _, n := range trustedProxies {
if !strings.Contains(n, "/") {
opt.AddTrustedProxy(n)
} else {
opt.AddTrustedNetwork(n)
}
}
return proxy.ForwardedHeaders(opt)
}
func MustInitSessioner() func(next http.Handler) http.Handler {
// TODO: CHI-SESSION-GOB-REGISTER: chi-session has a design problem: it calls gob.Register for "Set"
// But if the server restarts, then the first "Get" will fail to decode the previously stored session data because the structs are not registered yet.
// So each package should make sure their structs are registered correctly during startup for session storage.
middleware, err := session.Sessioner(session.Options{
Provider: setting.SessionConfig.Provider,
ProviderConfig: setting.SessionConfig.ProviderConfig,
CookieName: setting.SessionConfig.CookieName,
CookiePath: setting.SessionConfig.CookiePath,
Gclifetime: setting.SessionConfig.Gclifetime,
Maxlifetime: setting.SessionConfig.Maxlifetime,
Secure: setting.SessionConfig.Secure,
SameSite: setting.SessionConfig.SameSite,
Domain: setting.SessionConfig.Domain,
// in the future, if websocket is used, the websocket handler should manage its own session sync (release)
IgnoreReleaseForWebSocket: true,
})
if err != nil {
log.Fatal("common.Sessioner failed: %v", err)
}
return middleware
}

View File

@@ -0,0 +1,83 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
goctx "context"
"errors"
"sync"
activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/log"
"gitea.dev/services/context"
)
// StopwatchTmplInfo is a view on a stopwatch specifically for template rendering
type StopwatchTmplInfo struct {
IssueLink string
RepoSlug string
IssueIndex int64
Seconds int64
}
func getActiveStopwatch(ctx *context.Context) *StopwatchTmplInfo {
if ctx.Doer == nil {
return nil
}
_, sw, issue, err := issues_model.HasUserStopwatch(ctx, ctx.Doer.ID)
if err != nil {
if !errors.Is(err, goctx.Canceled) {
log.Error("Unable to HasUserStopwatch for user:%-v: %v", ctx.Doer, err)
}
return nil
}
if sw == nil || sw.ID == 0 {
return nil
}
return &StopwatchTmplInfo{
issue.Link(),
issue.Repo.FullName(),
issue.Index,
sw.Seconds() + 1, // ensure time is never zero in ui
}
}
func notificationUnreadCount(ctx *context.Context) int64 {
if ctx.Doer == nil {
return 0
}
count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
UserID: ctx.Doer.ID,
Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
})
if err != nil {
if !errors.Is(err, goctx.Canceled) {
log.Error("Unable to find notification for user:%-v: %v", ctx.Doer, err)
}
return 0
}
return count
}
type pageGlobalDataType struct {
IsSigned bool
IsSiteAdmin bool
GetNotificationUnreadCount func() int64
GetActiveStopwatch func() *StopwatchTmplInfo
}
func PageGlobalData(ctx *context.Context) {
var data pageGlobalDataType
data.IsSigned = ctx.Doer != nil
data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin
data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) })
data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) })
ctx.Data["PageGlobalData"] = data
}

123
routers/common/qos.go Normal file
View File

@@ -0,0 +1,123 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"fmt"
"net/http"
"strings"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web/middleware"
"gitea.dev/modules/web/routing"
"github.com/bohde/codel"
"github.com/go-chi/chi/v5"
)
const tplStatus503 templates.TplName = "status/503"
type Priority int
func (p Priority) String() string {
switch p {
case HighPriority:
return "high"
case DefaultPriority:
return "default"
case LowPriority:
return "low"
default:
return fmt.Sprintf("%d", p)
}
}
const (
LowPriority = Priority(-10)
DefaultPriority = Priority(0)
HighPriority = Priority(10)
)
// QoS implements quality of service for requests, based upon whether
// or not the user is logged in. All traffic may get dropped, and
// anonymous users are deprioritized.
func QoS() func(next http.Handler) http.Handler {
if !setting.Service.QoS.Enabled {
return nil
}
maxOutstanding := setting.Service.QoS.MaxInFlightRequests
if maxOutstanding <= 0 {
maxOutstanding = 10
}
c := codel.NewPriority(codel.Options{
// The maximum number of waiting requests.
MaxPending: setting.Service.QoS.MaxWaitingRequests,
// The maximum number of in-flight requests.
MaxOutstanding: maxOutstanding,
// The target latency that a blocked request should wait
// for. After this, it might be dropped.
TargetLatency: setting.Service.QoS.TargetWaitTime,
})
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
reqRecordInfo := routing.GetRequestRecordInfo(ctx)
priority := requestPriority(ctx)
// Check if the request can begin processing.
err := c.Acquire(ctx, int(priority))
if err != nil {
log.Error("QoS error, dropping request of priority %s: %v", priority, err)
renderServiceUnavailable(w, req)
return
}
// Release long-polling immediately, so they don't always take up an in-flight request
if reqRecordInfo.IsLongPolling {
c.Release()
} else {
defer c.Release()
}
next.ServeHTTP(w, req)
})
}
}
// requestPriority assigns a priority value for a request based upon
// whether the user is logged in and how expensive the endpoint is
func requestPriority(ctx context.Context) Priority {
// If the user is logged in, assign high priority.
data := middleware.GetContextData(ctx)
if _, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
return HighPriority
}
rctx := chi.RouteContext(ctx)
if rctx == nil {
return DefaultPriority
}
// If we're operating in the context of a repo, assign low priority
routePattern := rctx.RoutePattern()
if strings.HasPrefix(routePattern, "/{username}/{reponame}/") {
return LowPriority
}
return DefaultPriority
}
// renderServiceUnavailable will render an HTTP 503 Service
// Unavailable page, providing HTML if the client accepts it.
func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) {
ctxData := middleware.GetContextData(req.Context())
renderServerErrorPage(w, req, http.StatusServiceUnavailable, tplStatus503, ctxData, "Service Unavailable")
}

View File

@@ -0,0 +1,63 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
user_model "gitea.dev/models/user"
"gitea.dev/modules/web/middleware"
"gitea.dev/services/contexttest"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
)
func TestRequestPriority(t *testing.T) {
type test struct {
Name string
User *user_model.User
RoutePattern string
Expected Priority
}
cases := []test{
{
Name: "Logged In",
User: &user_model.User{},
Expected: HighPriority,
},
{
Name: "Sign In",
RoutePattern: "/user/login",
Expected: DefaultPriority,
},
{
Name: "Repo Home",
RoutePattern: "/{username}/{reponame}",
Expected: DefaultPriority,
},
{
Name: "User Repo",
RoutePattern: "/{username}/{reponame}/src/branch/main",
Expected: LowPriority,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
ctx, _ := contexttest.MockContext(t, "")
if tc.User != nil {
data := middleware.GetContextData(ctx)
data[middleware.ContextDataKeySignedUser] = tc.User
}
rctx := chi.RouteContext(ctx)
rctx.RoutePatterns = []string{tc.RoutePattern}
assert.Exactly(t, tc.Expected, requestPriority(ctx))
})
}
}

View File

@@ -0,0 +1,27 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"gitea.dev/modules/httplib"
)
// FetchRedirectDelegate helps the "fetch" requests to redirect to the correct location
func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
// When use "fetch" to post requests and the response is a redirect, browser's "location.href = uri" has limitations.
// 1. change "location" from old "/foo" to new "/foo#hash", the browser will not reload the page.
// 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target.
// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
// then frontend needs this delegate to redirect to the new location with hash correctly.
redirect := req.FormValue("redirect")
if req.Method != http.MethodPost || !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
http.Error(resp, "Bad Request", http.StatusBadRequest)
return
}
// no OpenRedirect, the "redirect" is validated by "IsCurrentGiteaSiteURL" above
resp.Header().Set("Location", redirect)
resp.WriteHeader(http.StatusSeeOther)
}

View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestFetchRedirectDelegate(t *testing.T) {
defer test.MockVariableValue(&setting.AppURL, "https://gitea/")()
cases := []struct {
method string
input string
status int
}{
{method: "POST", input: "/foo?k=v", status: http.StatusSeeOther},
{method: "GET", input: "/foo?k=v", status: http.StatusBadRequest},
{method: "POST", input: `\/foo?k=v`, status: http.StatusBadRequest},
{method: "POST", input: `\\/foo?k=v`, status: http.StatusBadRequest},
{method: "POST", input: "https://gitea/xxx", status: http.StatusSeeOther},
{method: "POST", input: "https://other/xxx", status: http.StatusBadRequest},
}
for _, c := range cases {
t.Run(c.method+" "+c.input, func(t *testing.T) {
resp := httptest.NewRecorder()
req := httptest.NewRequest(c.method, "/?redirect="+url.QueryEscape(c.input), nil)
FetchRedirectDelegate(resp, req)
assert.Equal(t, c.status, resp.Code)
if c.status == http.StatusSeeOther {
assert.Equal(t, c.input, resp.Header().Get("Location"))
} else {
assert.Empty(t, resp.Header().Get("Location"))
assert.Equal(t, "Bad Request", strings.TrimSpace(resp.Body.String()))
}
})
}
}

45
routers/common/serve.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"path"
"time"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/modules/httpcache"
"gitea.dev/modules/httplib"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/services/context"
)
// ServeBlob download a git.Blob
func ServeBlob(ctx *context.Base, repo *repo_model.Repository, filePath string, blob *git.Blob, lastModified *time.Time) error {
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
return nil
}
if err := repo.LoadOwner(ctx); err != nil {
return err
}
dataRc, err := blob.DataAsync(ctx)
if err != nil {
return err
}
defer dataRc.Close()
if lastModified == nil {
lastModified = new(time.Time)
}
httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, blob.Size(ctx), dataRc, httplib.ServeHeaderOptions{
Filename: path.Base(filePath),
CacheIsPublic: !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic,
CacheDuration: setting.StaticCacheTime,
LastModified: *lastModified,
})
return nil
}