diff options
Diffstat (limited to 'routers/common')
-rw-r--r-- | routers/common/actions.go | 71 | ||||
-rw-r--r-- | routers/common/blockexpensive.go | 90 | ||||
-rw-r--r-- | routers/common/blockexpensive_test.go | 30 | ||||
-rw-r--r-- | routers/common/db.go | 4 | ||||
-rw-r--r-- | routers/common/markup.go | 8 | ||||
-rw-r--r-- | routers/common/pagetmpl.go | 83 | ||||
-rw-r--r-- | routers/common/qos.go | 145 | ||||
-rw-r--r-- | routers/common/qos_test.go | 91 |
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") + }) +} |