aboutsummaryrefslogtreecommitdiffstats
path: root/routers/common
diff options
context:
space:
mode:
Diffstat (limited to 'routers/common')
-rw-r--r--routers/common/actions.go71
-rw-r--r--routers/common/blockexpensive.go90
-rw-r--r--routers/common/blockexpensive_test.go30
-rw-r--r--routers/common/db.go4
-rw-r--r--routers/common/markup.go8
-rw-r--r--routers/common/pagetmpl.go83
-rw-r--r--routers/common/qos.go145
-rw-r--r--routers/common/qos_test.go91
8 files changed, 518 insertions, 4 deletions
diff --git a/routers/common/actions.go b/routers/common/actions.go
new file mode 100644
index 0000000000..a4eabb6ba2
--- /dev/null
+++ b/routers/common/actions.go
@@ -0,0 +1,71 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "fmt"
+ "strings"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/actions"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/context"
+)
+
+func DownloadActionsRunJobLogsWithIndex(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobIndex int64) error {
+ runJobs, err := actions_model.GetRunJobsByRunID(ctx, runID)
+ if err != nil {
+ return fmt.Errorf("GetRunJobsByRunID: %w", err)
+ }
+ if err = runJobs.LoadRepos(ctx); err != nil {
+ return fmt.Errorf("LoadRepos: %w", err)
+ }
+ if jobIndex < 0 || jobIndex >= int64(len(runJobs)) {
+ return util.NewNotExistErrorf("job index is out of range: %d", jobIndex)
+ }
+ return DownloadActionsRunJobLogs(ctx, ctxRepo, runJobs[jobIndex])
+}
+
+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")
+ }
+
+ if curJob.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, curJob.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",
+ ContentTypeCharset: "utf-8",
+ Disposition: "attachment",
+ })
+ return nil
+}
diff --git a/routers/common/blockexpensive.go b/routers/common/blockexpensive.go
new file mode 100644
index 0000000000..f52aa2b709
--- /dev/null
+++ b/routers/common/blockexpensive.go
@@ -0,0 +1,90 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/reqctx"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web/middleware"
+
+ "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, setting.AppSubURL+"/user/login", 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}/graph",
+ "/{username}/{reponame}/media/",
+ "/{username}/{reponame}/raw/",
+ "/{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 isRoutePathForLongPolling(routePattern string) bool {
+ return routePattern == "/user/events"
+}
+
+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 = isRoutePathForLongPolling(chiRoutePath)
+ }
+ return ret
+}
diff --git a/routers/common/blockexpensive_test.go b/routers/common/blockexpensive_test.go
new file mode 100644
index 0000000000..db5c0db7dd
--- /dev/null
+++ b/routers/common/blockexpensive_test.go
@@ -0,0 +1,30 @@
+// 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)
+ }
+
+ assert.True(t, isRoutePathForLongPolling("/user/events"))
+}
diff --git a/routers/common/db.go b/routers/common/db.go
index cb163c867d..01c0261427 100644
--- a/routers/common/db.go
+++ b/routers/common/db.go
@@ -5,7 +5,7 @@ package common
import (
"context"
- "fmt"
+ "errors"
"time"
"code.gitea.io/gitea/models/db"
@@ -25,7 +25,7 @@ func InitDBEngine(ctx context.Context) (err error) {
for i := 0; i < setting.Database.DBConnectRetries; i++ {
select {
case <-ctx.Done():
- return fmt.Errorf("Aborted due to shutdown:\nin retry ORM engine initialization")
+ 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)
diff --git a/routers/common/markup.go b/routers/common/markup.go
index 60bf0dba54..00b2dd07c6 100644
--- a/routers/common/markup.go
+++ b/routers/common/markup.go
@@ -76,7 +76,11 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
})
rctx = rctx.WithMarkupType(markdown.MarkupName)
case "comment":
- rctx = renderhelper.NewRenderContextRepoComment(ctx, repoModel, renderhelper.RepoCommentOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName})
+ 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})
@@ -88,7 +92,7 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
})
rctx = rctx.WithMarkupType("").WithRelativePath(filePath) // render the repo file content by its extension
default:
- ctx.HTTPError(http.StatusUnprocessableEntity, fmt.Sprintf("Unknown mode: %s", mode))
+ ctx.HTTPError(http.StatusUnprocessableEntity, "Unknown mode: "+mode)
return
}
rctx = rctx.WithUseAbsoluteLink(true)
diff --git a/routers/common/pagetmpl.go b/routers/common/pagetmpl.go
new file mode 100644
index 0000000000..c48596d48b
--- /dev/null
+++ b/routers/common/pagetmpl.go
@@ -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 "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/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
+}
diff --git a/routers/common/qos.go b/routers/common/qos.go
new file mode 100644
index 0000000000..e50fbe4f69
--- /dev/null
+++ b/routers/common/qos.go
@@ -0,0 +1,145 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/web/middleware"
+ giteacontext "code.gitea.io/gitea/services/context"
+
+ "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()
+
+ 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 strings.Contains(req.URL.Path, "/user/events") {
+ 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) {
+ acceptsHTML := false
+ for _, part := range req.Header["Accept"] {
+ if strings.Contains(part, "text/html") {
+ acceptsHTML = true
+ break
+ }
+ }
+
+ // If the client doesn't accept HTML, then render a plain text response
+ if !acceptsHTML {
+ http.Error(w, "503 Service Unavailable", http.StatusServiceUnavailable)
+ return
+ }
+
+ tmplCtx := giteacontext.TemplateContext{}
+ tmplCtx["Locale"] = middleware.Locale(w, req)
+ ctxData := middleware.GetContextData(req.Context())
+ err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx)
+ if err != nil {
+ log.Error("Error occurs again when rendering service unavailable page: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("Internal server error, please collect error logs and report to Gitea issue tracker"))
+ }
+}
diff --git a/routers/common/qos_test.go b/routers/common/qos_test.go
new file mode 100644
index 0000000000..850a5f51db
--- /dev/null
+++ b/routers/common/qos_test.go
@@ -0,0 +1,91 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package common
+
+import (
+ "net/http"
+ "testing"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/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))
+ })
+ }
+}
+
+func TestRenderServiceUnavailable(t *testing.T) {
+ t.Run("HTML", func(t *testing.T) {
+ ctx, resp := contexttest.MockContext(t, "")
+ ctx.Req.Header.Set("Accept", "text/html")
+
+ renderServiceUnavailable(resp, ctx.Req)
+ assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
+ assert.Contains(t, resp.Header().Get("Content-Type"), "text/html")
+
+ body := resp.Body.String()
+ assert.Contains(t, body, `lang="en-US"`)
+ assert.Contains(t, body, "503 Service Unavailable")
+ })
+
+ t.Run("plain", func(t *testing.T) {
+ ctx, resp := contexttest.MockContext(t, "")
+ ctx.Req.Header.Set("Accept", "text/plain")
+
+ renderServiceUnavailable(resp, ctx.Req)
+ assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
+ assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain")
+
+ body := resp.Body.String()
+ assert.Contains(t, body, "503 Service Unavailable")
+ })
+}