初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error {
|
||||
job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := job.LoadRepo(ctx); err != nil {
|
||||
return fmt.Errorf("LoadRepo: %w", err)
|
||||
}
|
||||
return DownloadActionsRunJobLogs(ctx, ctxRepo, job)
|
||||
}
|
||||
|
||||
func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error {
|
||||
if curJob.Repo.ID != ctxRepo.ID {
|
||||
return util.NewNotExistErrorf("job not found")
|
||||
}
|
||||
|
||||
taskID := curJob.EffectiveTaskID()
|
||||
if taskID == 0 {
|
||||
return util.NewNotExistErrorf("job not started")
|
||||
}
|
||||
|
||||
if err := curJob.LoadRun(ctx); err != nil {
|
||||
return fmt.Errorf("LoadRun: %w", err)
|
||||
}
|
||||
|
||||
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 := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenLogs: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
workflowName := curJob.Run.WorkflowID
|
||||
if p := strings.Index(workflowName, "."); p > 0 {
|
||||
workflowName = workflowName[0:p]
|
||||
}
|
||||
ctx.ServeContent(reader, context.ServeHeaderOptions{
|
||||
Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, curJob.Name, task.ID),
|
||||
ContentLength: &task.LogSize,
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
ContentDisposition: httplib.ContentDispositionAttachment,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"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"].(string) == 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
|
||||
} 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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, headRef := parseHead(routerParam)
|
||||
return &CompareRouterReq{
|
||||
HeadOriRef: headRef,
|
||||
HeadOwner: headOwnerName,
|
||||
HeadRepoName: headRepoName,
|
||||
CompareSeparator: "...",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ci := &CompareRouterReq{CompareSeparator: sep}
|
||||
ci.BaseOriRef, ci.BaseOriRefSuffix = git.ParseRefSuffix(basePart)
|
||||
ci.HeadOwner, ci.HeadRepoName, ci.HeadOriRef = parseHead(headPart)
|
||||
return ci
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.CompareRouterReq, ParseCompareRouterParam(c.input), "input: %s", c.input)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/migrations"
|
||||
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 := migrations.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 := migrations.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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{NoTransform: true})
|
||||
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 {
|
||||
_, _ = 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)
|
||||
}
|
||||
@@ -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.Context()))
|
||||
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.Context()))
|
||||
renderServiceUnavailable(w, req)
|
||||
assert.Equal(t, "Service Unavailable", w.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/optional"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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...)...)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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"
|
||||
|
||||
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"
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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
|
||||
}
|
||||
}()
|
||||
|
||||
ds := reqctx.GetRequestDataStore(ctx)
|
||||
req = req.WithContext(cache.WithCacheContext(ctx))
|
||||
ds.SetContextValue(httplib.RequestContextKey, req)
|
||||
ds.AddCleanUp(func() {
|
||||
// TODO: GOLANG-HTTP-TMPDIR: Golang saves the uploaded files to temp directory (TMPDIR) when parsing multipart-form.
|
||||
// The "req" might have changed due to the new "req.WithContext" calls
|
||||
// For example: in NewBaseContext, a new "req" with context is created, and the multipart-form is parsed there.
|
||||
// So we always use the latest "req" from the data store.
|
||||
ctxReq := ds.GetContextValue(httplib.RequestContextKey).(*http.Request)
|
||||
if ctxReq.MultipartForm != nil {
|
||||
_ = ctxReq.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory
|
||||
}
|
||||
})
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("common.Sessioner failed: %v", err)
|
||||
}
|
||||
return middleware
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dataRc.Close()
|
||||
|
||||
if lastModified == nil {
|
||||
lastModified = new(time.Time)
|
||||
}
|
||||
httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, blob.Size(), dataRc, httplib.ServeHeaderOptions{
|
||||
Filename: path.Base(filePath),
|
||||
CacheIsPublic: !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic,
|
||||
CacheDuration: setting.StaticCacheTime,
|
||||
LastModified: *lastModified,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user