Gitea源码深度剖析-第3章 Web 路由与中间件体系
第 1 章用一节的篇幅描述了"一个 HTTP 请求进入 Gitea 之后会经过哪些阶段",那一节里的每一条横线,在源码里都对应一个真实的函数:它们有的挂在路由匹配之前,有的挂在匹配之后,有的是被反射包装过的业务 handler。本章把 1.5 节逐层放大,回答四个问题:
- Gitea 用什么框架接收 HTTP 请求,为什么在框架之上又包了一层
modules/web? - 一个 handler 是怎样从
func(ctx *context.Context)变成http.HandlerFunc的?写错签名为什么能在启动时就被发现? - 从 TCP 字节流到业务函数之间,中间件的准确执行顺序是什么?每层各管什么?
- 静态资源、缓存头、错误页这些"横切面"分别由谁负责?
本章所有代码摘录均来自当前源码树(Go module gitea.dev,go-chi/chi v5),每段都标注文件路径与函数名;凡是涉及历史沿革的断言,均注明出处。本章不深入仓库/组织权限判定的内部逻辑(那是第 5、6 章的内容),也不展开 REST API 的具体端点设计(第 9 章),但会给出它们与本章机制的挂接点。
3.1 路由框架选型:go-chi 与 modules/web 封装层
3.1.1 依赖清单里的直接证据
先看 go.mod 里与路由相关的直接依赖(require 第一段,非 // indirect):
github.com/go-chi/chi/v5 v5.3.2
github.com/go-chi/cors
gitea.com/go-chi/binding
gitea.com/go-chi/cache
gitea.com/go-chi/captcha
gitea.com/go-chi/session
github.com/chi-middleware/proxy
而 github.com/gorilla/mux v1.8.1 在 go.mod 里的状态是:
github.com/gorilla/mux v1.8.1 // indirect
// indirect 意味着 Gitea 自身的源码没有 import 它,它只是被某个传递依赖带进来的。用全库检索可以验证这一点:在整个源码树中检索 gorilla/mux 的 import 语句,结果为零(gorilla/sessions 与 gorilla/feeds 是另外两个库,与 mux 无关)。
所以"Gitea 从 gorilla/mux 迁到 go-chi"这个说法在当前源码上并不成立——mux 从未是主角。真实的迁移史是 macaron → chi:
- Gitea 早期(含 Gogs 血统)使用自研的 macaron 框架;
- 2019 年的 go-gitea#7420 引入了 chi 相关工作;
- 1.15 里程碑的 go-gitea#14293 "Move macaron to chi" 完成了主体迁移,被列为 BREAKING 变更;
- go-gitea#14491 随后移除了 macaron 的文档引用。
(以上 PR 编号与标题来自对 go-gitea GitHub 历史的检索;由于本地源码树不含 .git 目录,无法用 git log 复核,读者可按编号自行核对。)
关键在于:迁移之后 Gitea 并没有让业务代码直接裸用 chi,而是在其上构建了 modules/web 这一层薄封装。这就是 routers/web/web.go 里到处都是 m.Get、m.Group、web.Bind[...]() 而不是 chi.Mux 方法的原因。
3.1.2 modules/web 与 routers/web/web.go 的关系
两个包的分工非常清晰:
| 包 | 职责 | 关键类型 |
|---|---|---|
modules/web | 路由封装、handler 反射分发、中间件两阶段装配、路由日志(routing/) | Router、Group、Combo、toHandlerProvider |
routers/web | 真正的 URL 注册表与业务 handler 的组织 | Routes()、registerWebRoutes() |
routers/common | 协议级全局中间件(panic 恢复、安全头、反代头、访问日志…) | ProtocolMiddlewares() |
services/context | Web/API 的请求上下文对象与响应辅助 | Contexter()、Context、APIContexter() |
modules/web 不 import routers/*,它唯一的"插件点"是 RegisterResponseStatusProvider(见 3.4 节),由 services/context 在 init() 里注册自己。这种依赖方向保证了封装层可以独立测试——modules/web/router_test.go 就是不启动整个 Gitea 而验证中间件顺序的地方。
3.1.3 NormalRoutes():整个实例的路由总装
进程入口在 cmd/web.go 的 serveInstalled():
// cmd/web.go
// Set up Chi routes
webRoutes := routers.NormalRoutes()
routers.NormalRoutes()(routers/init.go:176)是所有已安装实例的总路由,全文如下(省略与路由结构无关的注释):
// NormalRoutes represents non install routes
func NormalRoutes() *web.Router {
r := web.NewRouter()
r.BeforeRouting(common.ProtocolMiddlewares()...)
r.AfterRouting(common.MaintenanceModeHandler())
r.Mount("/", web_routers.Routes())
r.Mount("/api/v1", apiv1.Routes())
r.Mount("/api/internal", private.Routes())
r.Post("/-/fetch-redirect", common.FetchRedirectDelegate)
if setting.Packages.Enabled {
// This implements package support for most package managers
r.Mount("/api/packages", packages_router.CommonRoutes())
// This implements the OCI API, this container registry "/v2" endpoint must be in the root of the site.
// If site admin deploys Gitea in a sub-path, they must configure their reverse proxy to map the "https://host/v2" endpoint to Gitea.
r.Mount("/v2", packages_router.ContainerRoutes())
}
if setting.Actions.Enabled {
prefix := "/api/actions"
r.Mount(prefix, actions_router.Routes(prefix))
// TODO: Pipeline api used for runner internal communication with gitea server. but only artifact is used for now.
// In Github, it uses ACTIONS_RUNTIME_URL=https://pipelines.actions.githubusercontent.com/fLgcSHkPGySXeIFrg8W8OBSfeg3b5Fls1A1CwX566g8PayEGlg/
// TODO: this prefix should be generated with a token string with runner ?
prefix = "/api/actions_pipeline"
r.Mount(prefix, actions_router.ArtifactsRoutes(prefix))
prefix = actions_router.ArtifactV4RouteBase
r.Mount(prefix, actions_router.ArtifactsV4Routes(prefix))
}
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
defer routing.RecordFuncInfo(req.Context(), routing.GetFuncInfo(http.NotFound, "GlobalNotFound"))()
http.NotFound(w, req)
})
return r
}
结构上只有四类动作:
BeforeRouting:挂common.ProtocolMiddlewares()——这一组是"路由匹配之前"的全局中间件,允许改写请求的 method 与 path(3.2 节展开);AfterRouting:挂common.MaintenanceModeHandler()——所有子路由共用的"匹配之后"的第一道闸;Mount:五个(或按配置更多)子路由组;/挂 Web UI,/api/v1挂 REST API,/api/internal挂内部协议,包管理与 Actions 按 feature gate 条件挂载;NotFound:兜底处理器,注意它显式记录FuncInfo,使 404 请求在路由日志里也能显示处理函数名。
值得注意的两个设计细节:
- OCI 的
/v2必须在站点根。注释解释了为什么容器 registry 端点不能像其他 API 一样塞进子路径——这也解释了normalizeRequestPath里对/v2的特判(见 3.2.4)。 /-/fetch-redirect是根路由上的单个 POST。它是前端fetch跳转的委托出口,因为浏览器对fetch响应中的 3xx 不便统一处理,于是改成"POST 到这里,由后端校验后发跳转"(common.FetchRedirectDelegate会校验目标必须是当前站点 URL,防止开放重定向)。
除此之外,"安装页"是另一套路由:routers/install/routes.go 自己建 Router、自己挂 ProtocolMiddlewares,由 cmd/web.go 的 serveInstall() 在未安装状态下单独 listen。3.8.4 会展示它的差异。
3.1.4 一个请求要穿过几层 Router
NormalRoutes() 的 Mount 关系画成图:
graph TD
A["cmd/web.go: listen(webRoutes)"] --> B["routers.NormalRoutes() — 根 Router"]
B -->|"BeforeRouting(ProtocolMiddlewares...)"| P["ChiRoutePath → RequestContext → SecurityHeaders → ForwardedHeaders? → RequestInfo → AccessLogger? → ViteDev?"]
B -->|"AfterRouting"| MM["MaintenanceModeHandler"]
B -->|"Mount /"| W["routers/web.Routes()"]
B -->|"Mount /api/v1"| API["routers/api/v1.Routes()"]
B -->|"Mount /api/internal"| PRI["routers/private.Routes()"]
B -->|"Mount /api/packages, /v2"| PKG["packages_router (Packages.Enabled)"]
B -->|"Mount /api/actions*"| ACT["actions_router (Actions.Enabled)"]
W -->|"BeforeRouting(GetHead)"| GH["chi GetHead"]
W -->|"Mount ''"| WR["webRoutes + AfterRouting(mid...)"]
WR --> REG["registerWebRoutes(): repo/org/admin/user/... 分组"]
这里有个容易误读的点:一次请求可能多次经过 Router.ServeHTTP——Mount 把子 Router 的 chiRouter 挂进父 Router,子 Router 自己的 ServeHTTP(内含 normalizeRequestPath)也会被执行。router.go 为此留了 TODO:
// modules/web/router.go
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// TODO: need to move it to the top-level common middleware, otherwise each "Mount" will cause it to be executed multiple times, which is inefficient.
r.normalizeRequestPath(w, req, r.chiRouter)
}
即路径规范化目前是"每个 Mount 层各做一遍",作者明知低效但保持行为不变——这是理解 3.2.4 的前提。
3.2 modules/web 的 Router/Group API 与两阶段中间件
3.2.1 Router 结构与四个注册原语
// modules/web/router.go
// Router defines a route based on chi's router
type Router struct {
chiRouter *chi.Mux
afterRouting []any
curGroupPrefix string
curMiddlewares []any
}
// NewRouter creates a new route
func NewRouter() *Router {
r := chi.NewRouter()
return &Router{chiRouter: r}
}
四个字段中,curGroupPrefix 与 curMiddlewares 是注册期状态:Group 通过保存/恢复它们实现嵌套前缀与继承中间件:
// Group mounts a sub-router along a "pattern" string.
func (r *Router) Group(pattern string, fn func(), middlewares ...any) {
previousGroupPrefix := r.curGroupPrefix
previousMiddlewares := r.curMiddlewares
r.curGroupPrefix += pattern
r.curMiddlewares = append(r.curMiddlewares, middlewares...)
fn()
r.curGroupPrefix = previousGroupPrefix
r.curMiddlewares = previousMiddlewares
}
这与 chi 自身的 RouteGroup 不同:Gitea 的 Group 不创建新的子 mux,而是在同一个 chi.Mux 上累积前缀与中间件,fn() 执行期间注册的每一条路由都会带上这些累积值。所以 Group 的中间件是"注册时快照",对 fn() 之后注册的路由无影响——这个语义决定了 3.6 节里"把中间件写在 Group 参数里还是写在路由参数里"的等价性(二者都进 curMiddlewares,只是时机不同)。
getPattern 负责把前缀和路由 pattern 拼成合法 chi 路径(保证以 / 开头、去掉尾部 /,根除外):
func (r *Router) getPattern(pattern string) string {
newPattern := r.curGroupPrefix + pattern
if !strings.HasPrefix(newPattern, "/") {
newPattern = "/" + newPattern
}
if newPattern == "/" {
return newPattern
}
return strings.TrimSuffix(newPattern, "/")
}
HTTP 动词方法全部收敛到 Methods:
// Methods adds the same handlers for multiple http "methods" (separated by ",").
// If any method is invalid, the lower level router will panic.
func (r *Router) Methods(methods, pattern string, h ...any) {
middlewares, handlerFunc, _ := wrapMiddlewareAndHandler(r.afterRouting, r.curMiddlewares, h)
fullPattern := r.getPattern(pattern)
if strings.Contains(methods, ",") {
methods := strings.SplitSeq(methods, ",")
for method := range methods {
r.chiRouter.With(middlewares...).Method(strings.TrimSpace(method), fullPattern, handlerFunc)
}
} else {
r.chiRouter.With(middlewares...).Method(methods, fullPattern, handlerFunc)
}
}
Get/Post/Put/Delete/Patch/Head/Any 都是 Methods 的别名;Combo 允许对同一 pattern 链式声明多个动词:
// Combo delegates requests to Combo
func (r *Router) Combo(pattern string, h ...any) *Combo {
return &Combo{r, pattern, h}
}
典型用法(routers/web/web.go):
m.Combo("/login/openid").
...
m.Combo("/{runnerid}").Get(shared_actions.RunnersEdit).
...
还有一个"逃生舱"PathGroup——当 chi 的占位符无法表达某条规则时,用正则穷举匹配(源码注释直言:低效,非必要不用):
// PathGroup creates a group of paths which could be matched by regexp.
// It is only designed to resolve some special cases which chi router can't handle.
// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
func (r *Router) PathGroup(pattern string, fn func(g *RouterPathGroup), h ...any) {
g := &RouterPathGroup{r: r, pathParam: "*"}
fn(g)
r.Any(pattern, append(h, g.ServeHTTP)...)
}
3.2.2 BeforeRouting 与 AfterRouting:为什么必须分两阶段
// BeforeRouting adds middlewares which will be executed before the request path gets routed
// It should only be used for framework-level global middlewares when it needs to change request method & path.
func (r *Router) BeforeRouting(middlewares ...any) {
for _, m := range middlewares {
if !isNilOrFuncNil(m) {
r.chiRouter.Use(toHandlerProvider(m))
}
}
}
// AfterRouting adds middlewares which will be executed after the request path gets routed
// It can see the routed path and resolved path parameters
func (r *Router) AfterRouting(middlewares ...any) {
r.afterRouting = append(r.afterRouting, middlewares...)
}
两者的实现差异决定了能力边界:
BeforeRouting直接chiRouter.Use——chi 的Use中间件在路由树匹配之前执行,此时chi.RouteContext(req.Context()).RoutePattern()还是空的,但正因如此,它可以改写req.URL.Path或 method,让后面的匹配基于改写后的路径。ChiRoutePathHandler(把%2f正确交给路由树)和normalizeRequestPath之后的一切都依赖它先行。AfterRouting先存进afterRouting字段,在每次Methods/Any/Mount注册时通过chiRouter.With(...)绑到具体路由上——匹配之后执行,此时 RoutePattern、URL 参数({reponame}等)都已解析完毕,且未匹配的路由不会执行它们(NotFound分支会另外带上这些中间件,见下)。
NotFound 的处理展示了如何让"未命中"也享受 AfterRouting 中间件:
// NotFound defines a handler to respond whenever a route could not be found.
func (r *Router) NotFound(h http.HandlerFunc) {
middlewares, handlerFunc, _ := wrapMiddlewareAndHandler(r.afterRouting, r.curMiddlewares, []any{h})
r.chiRouter.NotFound(func(w http.ResponseWriter, r *http.Request) {
executeMiddlewaresHandler(w, r, middlewares, handlerFunc)
})
}
也就是说:404 页面同样经过 session、Contexter、auth 等中间件(否则 404 模板拿不到 locale 和用户信息),但只有根 Router 注册的 NotFound 生效——NormalRoutes() 注册的全局 404 因此是"协议层完整"的。
3.2.3 wrapMiddlewareAndHandler:pre/normal 分层的装配顺序
这是 modules/web 最核心的一段装配逻辑:
func wrapMiddlewareAndHandler(useMiddlewares, curMiddlewares, h []any) (_ []middlewareProvider, _ http.HandlerFunc, hasPreMiddlewares bool) {
if len(h) == 0 {
panic("no endpoint handler provided")
}
if isNilOrFuncNil(h[len(h)-1]) {
panic("endpoint handler can't be nil")
}
handlerProviders := make([]middlewareProvider, 0, len(useMiddlewares)+len(curMiddlewares)+len(h)+1)
handlerProviders = wrapMiddlewareAppendPre(handlerProviders, useMiddlewares)
handlerProviders = wrapMiddlewareAppendPre(handlerProviders, curMiddlewares)
handlerProviders = wrapMiddlewareAppendPre(handlerProviders, h)
hasPreMiddlewares = len(handlerProviders) > 0
handlerProviders = wrapMiddlewareAppendNormal(handlerProviders, useMiddlewares)
handlerProviders = wrapMiddlewareAppendNormal(handlerProviders, curMiddlewares)
handlerProviders = wrapMiddlewareAppendNormal(handlerProviders, h)
middlewares := handlerProviders[:len(handlerProviders)-1]
handlerFunc := handlerProviders[len(handlerProviders)-1](nil).ServeHTTP
mockPoint := RouterMockPoint(MockAfterMiddlewares)
if mockPoint != nil {
middlewares = append(middlewares, mockPoint)
}
return middlewares, handlerFunc, hasPreMiddlewares
}
先看两个分类器:
func wrapMiddlewareAppendPre(all []middlewareProvider, middlewares []any) []middlewareProvider {
for _, m := range middlewares {
if h, ok := m.(types.PreMiddlewareProvider); ok && h != nil {
all = append(all, toHandlerProvider(middlewareProvider(h)))
}
}
return all
}
func wrapMiddlewareAppendNormal(all []middlewareProvider, middlewares []any) []middlewareProvider {
for _, m := range middlewares {
if _, ok := m.(types.PreMiddlewareProvider); !ok && !isNilOrFuncNil(m) {
all = append(all, toHandlerProvider(m))
}
}
return all
}
types.PreMiddlewareProvider 的定义是 interface { ServeHTTP(http.ResponseWriter, *http.Request) }——任何同时实现了 http.Handler 的中间件都被归类为 "pre"。装配规则因此是:
[useMiddlewares 的 pre 段] + [curMiddlewares 的 pre 段] + [h 参数的 pre 段]
+ [useMiddlewares 的 normal 段] + [curMiddlewares 的 normal 段] + [h 参数的 normal 段]
其中 useMiddlewares = r.afterRouting(AfterRouting 全局中间件),curMiddlewares = 当前 Group 累积的中间件,h = 注册该路由时随传的中间件与最终 handler。所有 pre 段排在所有 normal 段之前,无论它们来自哪个来源。
最后一个元素被拆出来作为 endpoint:handlerProviders[len(handlerProviders)-1](nil).ServeHTTP——这里 (nil) 触发了 handler 的启动期预检(3.4.4),返回的闭包直接作为 http.HandlerFunc 交给 chi。
hasPreMiddlewares 标志告诉 chi 的 With 是否需要包一层(pre 中间件必须由 chi 在匹配前执行才能看到原始请求;返回 false 时路由直接以 handler 注册,避免无谓包装——这是 Methods 中 _ 丢弃它的原因,真正消费它的是 Mount/特殊路径,见 router.go 中 Methods 的返回值处理)。
3.2.4 路径规范化 normalizeRequestPath
ServeHTTP 在交给 chi 之前先做路径整形:
func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Request, next http.Handler) {
normalized := false
normalizedPath := req.URL.EscapedPath()
if normalizedPath == "" {
normalizedPath, normalized = "/", true
} else if normalizedPath != "/" {
normalized = strings.HasSuffix(normalizedPath, "/")
normalizedPath = strings.TrimRight(normalizedPath, "/")
}
removeRepeatedSlashes := strings.Contains(normalizedPath, "//")
normalized = normalized || removeRepeatedSlashes
// the following code block is a slow-path for replacing all repeated slashes "//" to one single "/"
// if the path doesn't have repeated slashes, then no need to execute it
if removeRepeatedSlashes {
buf := &strings.Builder{}
for i := 0; i < len(normalizedPath); i++ {
if i == 0 || normalizedPath[i-1] != '/' || normalizedPath[i] != '/' {
buf.WriteByte(normalizedPath[i])
}
}
normalizedPath = buf.String()
}
// If the config tells Gitea to use a sub-url path directly without reverse proxy,
// then we need to remove the sub-url path from the request URL path.
// But "/v2" is special for OCI container registry, it should always be in the root of the site.
if setting.UseSubURLPath {
remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/")
if ok {
normalizedPath = "/" + remainingPath
} else if normalizedPath == setting.AppSubURL {
normalizedPath = "/"
} else if !strings.HasPrefix(normalizedPath+"/", "/v2/") && !public.IsViteDevRequest(req) {
// do not respond to other requests, to simulate a real sub-path environment
resp.Header().Add("Content-Type", "text/html; charset=utf-8")
resp.WriteHeader(http.StatusNotFound)
_, _ = htmlutil.HTMLPrintf(resp, `404 page not found, sub-path is: <a href="%s">%s</a>`, setting.AppSubURL, setting.AppSubURL)
return
}
normalized = true
}
// if the path is normalized, then fill it back to the request
if normalized {
decodedPath, err := url.PathUnescape(normalizedPath)
if err != nil {
http.Error(resp, "400 Bad Request: unable to unescape path "+normalizedPath, http.StatusBadRequest)
return
}
req.URL.RawPath = normalizedPath
req.URL.Path = decodedPath
}
next.ServeHTTP(resp, req)
}
逐条拆解其行为与动机:
| 规则 | 行为 | 动机 |
|---|---|---|
去尾部 / | /user/ → /user | 路由只注册无尾斜杠形式,避免同一资源两个 URL(SEO 与权限判定一致性) |
折叠 // | /a//b → /a/b,用 strings.Builder 走慢路径 | 防止用重复斜杠绕过基于路径前缀的匹配/拦截逻辑 |
UseSubURLPath 剥前缀 | 剥掉 AppSubURL;不匹配者直接 404 | 反代不改写 path 时由 Gitea 自己消化子路径;返回带链接的 404 是为了告诉管理员"你的子路径配置和实际访问对不上" |
/v2 与 Vite dev 请求豁免 | 不匹配子路径也放行 | OCI 规范要求 /v2/ 在站点根(与 3.1.3 注释呼应);dev 模式的 HMR 请求走独立端口 |
| 回填 | RawPath = 规范化后的转义路径,Path = 解码后路径 | 保证 EscapedPath()/RawPath 对后续 ChiRoutePathHandler 一致 |
规范化完成后才进入 chi 匹配,因此路由表里永远只需要考虑"规范形态"的路径。
3.2.5 内置组件:健康检查、指标、pprof、CORS、mock point
modules/web 自身不实现业务,但 routers 层用它挂了一批基础设施路由,位置都在 routers/web/web.go 的 Routes() 开头(3.5.1 会给出完整顺序):
- 健康检查:
routes.Get("/api/healthz", healthcheck.Check)。routers/web/healthcheck/check.go的Check返回 RFC 健康检查草案格式的 JSON:status取pass/fail/warn,检查项为database:ping与cache:ping;数据库 ping 失败或 SQLite 文件缺失、缓存 ping 失败都会把总状态降为fail(HTTP 424StatusFailedDependency)。源码注释明确警告不要把这个端点当 Docker HEALTHCHECK 的重启触发器——数据库迁移期间它长时间不返回 pass,盲目重启会导致迁移永远完不成。 - 指标:
setting.Metrics.Enabled时routes.Get("/metrics", append(mid, Metrics)...);routers/web/metrics.go的 handler 用subtle.ConstantTimeCompare校验Authorization: Bearer,未启用匿名访问时拒绝抓取。 - pprof 不在 chi 路由里。
cmd/web.go的servePprof()单独起一个只听localhost:6060的http.NewServeMux:
func servePprof() {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
...
// The pprof server is for debug purpose only, it shouldn't be exposed on public network. At the moment, it's not worth introducing a configurable option for it.
log.Info("Starting pprof server on localhost:6060")
server := &http.Server{Addr: "localhost:6060", Handler: mux, ReadHeaderTimeout: 10 * time.Second}
...
}
动机写在注释里:调试用途、不应暴露公网、"目前不值得为它引入配置项"——于是用最便宜的方案(独立端口 + 标准库 mux)与业务路由彻底隔离。
- CORS:Web 侧
optionsCorsHandler()(routers/web/web.go)与 API 侧cors.Handler(routers/api/v1/api.go的BeforeRouting)是两套配置消费点,前者对非预检 OPTIONS 显式返回 405,防止 OPTIONS 误入 GET/POST 处理器。 - RouterMockPoint:
routemock.go提供的测试/调试钩子。RouterMockPointBeforeWebRoutes被放进mid尾部、MockAfterMiddlewares被wrapMiddlewareAndHandler追加到中间件链末尾——集成测试用它在"业务 handler 即将执行"的精确位置插入桩。 - 路由日志:
modules/web/routing/里的FuncInfo把每个 handler 标注为file:line(funcName),RecordFuncInfo在进入/退出时记录,慢请求(3s)、长轮询(MarkLongPolling)、TRACE 降级都挂在这套机制上,3.9 单独展开。
还有一个容易找错的位置:Gitea 没有 chi 层的 Timeout 中间件。任务清单里常被提到的"超时/齐截"在源码里分散为三处,均不在中间件链上:连接与单次写超时由 modules/graceful/server.go 的 server/PerWriteTimeout 配置(listener 层,http.Server 级别);请求级的"过载截断"由 3.5.6 的 QoS(codel 延迟超标丢包)承担;而 routers/common/deadline.go 名字带 deadline 但只是日期字符串解析工具,与请求超时无关——检索中间件时若按字面找它会扑空。这个"没有统一超时中间件"的设计与 Go 生态一致:http.Server 的 Read/Write timeout + context 取消已覆盖主要场景,再包一层 chi Timeout 反而会打断 WebSocket/长轮询(/-/ws)等合法长请求。
3.2.6 中间件执行顺序:以测试为证
modules/web/router_test.go 的 TestPreMiddlewareProvider 是全书引用的权威顺序证据。测试注册的期望序列(节选):
expectMiddlewares: []string{"before-root", "pre-root", "root", "mid", "end1"}
其中 before-root 是 BeforeRouting 注册的、pre-root 是 AfterRouting 中实现了 http.Handler 的 pre 型、root 是 AfterRouting 的普通型、mid 是 Group/路由中间件、end1 是 handler。子路由场景的期望:
["before-root", "root", "before-sub", "pre-sub", "sub", "mid", "end2"]
未匹配(404)场景:
["before-root", "root", "before-sub", "sub", "not-found"]
翻译成结论表:
| 阶段 | 内容 | 相对顺序 |
|---|---|---|
| ① chi 匹配前 | BeforeRouting(chi.Use) | 全局最先,可改写 path/method |
| ② pre 段 | afterRouting 的 pre → group 的 pre → 路由参数的 pre | 所有 pre 先于所有 normal |
| ③ normal 段 | afterRouting 的 normal → group 的 normal → 路由参数的 normal → handler | 组内保持注册顺序 |
| ④ 未命中 | ① → ② → ③的注册顺序 → NotFound handler | 不经过匹配期中间件之外的路由专属逻辑 |
注意子路由序列里 root(父 AfterRouting 的 normal 段)排在子的 pre 段之前——这是"Mount 时父的 afterRouting 先包、子路由自身中间件后包"的直接后果,也是阅读 3.6 节顺序表时的关键索引。
3.3 handler 机制:反射注入与启动期预检
Gitea 的业务 handler 长这样:
func SignIn(ctx *context.Context) { ... }
func RepoAssignment(ctx *context.Context) { ... }
它们不是 func(http.ResponseWriter, *http.Request),没有返回值,也无法直接喂给 chi。把它们变成 http.HandlerFunc 的就是 modules/web/handler.go。
3.3.1 responseStatusProviders 注册表
var responseStatusProviders = map[reflect.Type]func(req *http.Request) types.ResponseStatusProvider{}
func RegisterResponseStatusProvider[T any](fn func(req *http.Request) types.ResponseStatusProvider) {
responseStatusProviders[reflect.TypeFor[T]()] = fn
}
types.ResponseStatusProvider 只有一个方法 WrittenStatus() int——返回非 0 表示"响应已写出"。谁来注册?services/context/context.go 的 init():
func init() {
web.RegisterResponseStatusProvider[*Base](func(req *http.Request) web_types.ResponseStatusProvider {
return reqctx.MustContextValue[*Base](req.Context(), BaseContextKey)
})
web.RegisterResponseStatusProvider[*Context](func(req *http.Request) web_types.ResponseStatusProvider {
return reqctx.MustContextValue[*Context](req.Context(), WebContextKey)
})
}
services/context/api.go 同样注册了 *APIContext。于是整个 handler 体系的类型注入是开放的:任何包只要实现 WrittenStatus() 并注册,就能成为 handler 的参数类型。reqctx.MustContextValue 表明这些对象并不由反射构造,而是早已由 Contexter 等中间件塞进 req.Context()——反射只是"按类型取出来"。
为什么需要"响应是否已写出"这个信号?因为 modules/web 的中间件语义是短路式的:中间件/handler 执行完后,只有响应没被写过才继续调 next。3.3.4 展开。
3.3.2 responseWriter 包装
// responseWriter is a wrapper of http.ResponseWriter, to check whether the response has been written
type responseWriter struct {
respWriter http.ResponseWriter
status int
}
var _ types.ResponseStatusProvider = (*responseWriter)(nil)
func (r *responseWriter) WrittenStatus() int {
return r.status
}
func (r *responseWriter) Write(bytes []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
return r.respWriter.Write(bytes)
}
func (r *responseWriter) WriteHeader(statusCode int) {
r.status = statusCode
r.respWriter.WriteHeader(statusCode)
}
func (r *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := r.respWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, http.ErrNotSupported
}
三个细节:
Write隐式补200——符合 HTTP 语义(不调 WriteHeader 即默认 200),保证WrittenStatus()不会漏判"直接 Write"的响应。Hijack保留并转发(WebSocket、/-/ws长轮询需要),不支持时返回ErrNotSupported而不是 panic。- 如果传入的 writer 已经是
ResponseStatusProvider(比如上层已包过),则不再重复包装——toHandlerProvider里if _, ok := resp.(types.ResponseStatusProvider); !ok正是此意。
注意 routers/common/middleware.go 还有另一个包装 context.WrapResponseWriter(用于访问日志记录 status/size),两者目的相同但层级不同:协议层那个统计的是可能被 gzip 压缩后的字节数——源码注释直言"written size here is the compressed size"。
3.3.3 prepareHandleArgsIn:按类型注入参数
func prepareHandleArgsIn(resp http.ResponseWriter, req *http.Request, fn reflect.Value, fnInfo *routing.FuncInfo) []reflect.Value {
defer func() {
if recovered := recover(); recovered != nil {
err := fmt.Errorf("%v\n%s", recovered, log.Stack(2))
log.Error("unable to prepare handler arguments for %s: %v", fnInfo.String(), err)
panic(err)
}
}()
isPreCheck := req == nil
argsIn := make([]reflect.Value, fn.Type().NumIn())
for i := 0; i < fn.Type().NumIn(); i++ {
argTyp := fn.Type().In(i)
switch argTyp {
case respWriterType:
argsIn[i] = reflect.ValueOf(resp)
case httpReqType:
argsIn[i] = reflect.ValueOf(req)
default:
if argFn, ok := responseStatusProviders[argTyp]; ok {
if isPreCheck {
argsIn[i] = reflect.ValueOf(&responseWriter{})
} else {
argsIn[i] = reflect.ValueOf(argFn(req))
}
} else {
panic(fmt.Sprintf("unsupported argument type: %s", argTyp))
}
}
}
return argsIn
}
支持的参数类型只有三类:
http.ResponseWriter→ 传入当前 writer;*http.Request→ 传入当前请求;- 已注册的
responseStatusProvider类型(*context.Base、*context.Context、*context.APIContext等)→ 从请求上下文取; - 其他任何类型 →
panic("unsupported argument type")。
因此 handler 想拿"别的东西"(比如一个服务对象)不能加参数,而要从 ctx 或全局获取——这是刻意的约束:参数注入表在启动期一次性校验(3.3.4),运行期零决策成本。
isPreCheck := req == nil 是关键分支:预检时没有真实请求,注册类型用空 &responseWriter{} 占位。
3.3.4 toHandlerProvider:分发、短路与启动期预检
// toHandlerProvider converts a handler to a handler provider
// A handler provider is a function that takes a "next" http.Handler, it can be used as a middleware
func toHandlerProvider(handler any) middlewareProvider {
funcInfo := routing.GetFuncInfo(handler)
fn := reflect.ValueOf(handler)
if fn.Type().Kind() != reflect.Func {
panic(fmt.Sprintf("handler must be a function, but got %s", fn.Type()))
}
if hp, ok := handler.(middlewareProvider); ok {
return wrapHandlerProvider(hp, funcInfo)
} else if hp, ok := handler.(func(http.Handler) http.HandlerFunc); ok {
return wrapHandlerProvider(hp, funcInfo)
}
provider := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(respOrig http.ResponseWriter, req *http.Request) {
// wrap the response writer to check whether the response has been written
resp := respOrig
if _, ok := resp.(types.ResponseStatusProvider); !ok {
resp = &responseWriter{respWriter: resp}
}
// prepare the arguments for the handler and do pre-check
argsIn := prepareHandleArgsIn(resp, req, fn, funcInfo)
if req == nil {
preCheckHandler(fn, argsIn)
return // it's doing pre-check, just return
}
defer routing.RecordFuncInfo(req.Context(), funcInfo)()
ret := fn.Call(argsIn)
// handle the return value (no-op at the moment)
handleResponse(fn, ret)
// if the response has not been written, call the next handler
if next != nil && !hasResponseBeenWritten(argsIn) {
next.ServeHTTP(resp, req)
}
})
}
provider(nil).ServeHTTP(nil, nil) // do a pre-check to make sure all arguments and return values are supported
return provider
}
分三条路径:
- 标准中间件
func(http.Handler) http.Handler与http.HandlerFunc形态 → 走wrapHandlerProvider,只加一层RecordFuncInfo日志埋点,不进入反射路径(常规中间件零反射开销)。 - 反射型 handler → 每次请求:包 writer →
prepareHandleArgsIn→fn.Call→ 检查是否写出。 - 短路语义:
if next != nil && !hasResponseBeenWritten(argsIn) { next.ServeHTTP(...) }。这行是modules/web与普通 chi 中间件最大的差异——中间件写出了响应就不再往下走。对比标准func(http.Handler) http.Handler中间件必须自己return才能短路,反射型中间件只需调用ctx.HTML(...)/ctx.NotFound(),框架自动检测。
hasResponseBeenWritten 遍历所有注入参数里的 provider:
func hasResponseBeenWritten(argsIn []reflect.Value) bool {
for _, argIn := range argsIn {
if statusProvider, ok := reflect.TypeAssert[types.ResponseStatusProvider](argIn); ok {
if statusProvider.WrittenStatus() != 0 {
return true
}
}
}
return false
}
最后的 provider(nil).ServeHTTP(nil, nil) 是启动期预检:以 next=nil、req=nil 各调用一次。req==nil 触发 preCheckHandler:
// preCheckHandler checks whether the handler is valid, developers could get first-time feedback, all mistakes could be found at startup
func preCheckHandler(fn reflect.Value, argsIn []reflect.Value) {
hasStatusProvider := false
for _, argIn := range argsIn {
if _, hasStatusProvider = reflect.TypeAssert[types.ResponseStatusProvider](argIn); hasStatusProvider {
break
}
}
if !hasStatusProvider {
panic(fmt.Sprintf("handler should have at least one ResponseStatusProvider argument, but got %s", fn.Type()))
}
if fn.Type().NumOut() != 0 {
panic(fmt.Sprintf("handler should have no return value other than registered ones, but got %s", fn.Type()))
}
}
两条硬规则在路由注册时(即进程启动、NormalRoutes() 构建期间)就被强制:
- handler 至少要有一个
ResponseStatusProvider参数(否则短路检测无从谈起)——所以func(w http.ResponseWriter, r *http.Request)这种裸签名不能当反射型 handler; - handler 不得有返回值。
加上 prepareHandleArgsIn 对未知参数类型的 panic,三类常见手误(参数类型写错、忘了传 ctx、误加返回值)全部在启动阶段爆炸,而不是等到线上某个路由第一次被访问才 500。错误信息里带 fnInfo.String()(file:line(funcName)),定位直达源码行。
3.3.5 Bind[T]:表单绑定也是一个反射型中间件
路由注册里常见的 web.Bind[*forms.SignInForm]() 本身就是上述机制的一个普通使用者:
// modules/web/router.go
// Bind binding the request form to a form object and assign context data
func Bind[T middleware.Form]() http.HandlerFunc {
return func(resp http.ResponseWriter, req *http.Request) {
form, errs := middleware.BindFormValidate[T](req, validation.Binder())
ctx := reqctx.FromContext(req.Context())
data := ctx.GetData()
locale := ctx.Value(translation.ContextKey).(translation.Locale) //nolint:forcetypeassert // must exist
SetForm(data, form)
// Legacy template error handling: try to restore the form's values as much as possible,
// especially for RenderWithErrDeprecated to re-render the form with errors.
middleware.AssignForm(form, data)
errorMessage, errorFieldName, _ := middleware.BuildValidationErrorForUser(form, locale, errs)
if errorMessage != "" {
data["HasError"] = true
data["ErrorMsg"] = errorMessage
if errorFieldName != "" {
data["Err_"+errorFieldName] = true
}
}
}
}
行为链:
BindFormValidate[T]用validation.Binder()把请求表单解码进T并做校验;SetForm(data, form)把绑定结果放进请求数据仓库(键__form),供 handler 用web.GetForm[T](ctx)取回;AssignForm(form, data)把用户已填的值回写到模板数据——这是"校验失败后重新渲染表单还能看到刚才输入"的实现;BuildValidationErrorForUser把字段错误翻译成本地化消息写进ErrorMsg/HasError,模板据此渲染红字。
注意 Bind 不中断请求:它只记录错误,由后续 handler 调 ctx.HasError() 决定是重渲染还是继续。Bind 是 http.HandlerFunc 形态,因此在 wrapMiddlewareAndHandler 里被归入 normal 段,按注册顺序执行——这就是 m.Post("/login", web.Bind[*forms.SignInForm](), auth.SignInPost) 中"先绑定后处理"的顺序来源。
API 侧的对应物是 routers/api/v1/api.go 的 bind[T],语义不同——失败立即返回 422:
// bind binding an obj to a func(ctx *context.APIContext)
func bind[T any](tmpl T) any {
return func(ctx *context.APIContext) {
form, errs := middleware.BindFormAny(ctx.Req, validation.Binder(), tmpl)
if len(errs) > 0 {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Sprintf("%s: %s", errs[0].FieldNames, errs[0].Error()))
return
}
web.SetForm(ctx, form)
}
}
Web 表单(HTML 回显友好,错误进模板数据)与 API JSON(机器可读,错误立即 422)在同一套绑定设施上分叉,这是前后端两种交互模型的直接体现。
GetFetchActionForm[T](services/context/context.go)则是第三种形态,服务于"fetch-action"新式提交:校验失败直接 JSONErrorWithField 返回 JSON,且禁止与模板式 Bind 混用(源码直接 panic 提示 "don't mix fetch-action form validation with template-based form validation")。
3.4 错误处理与响应辅助
3.4.1 Context 的响应辅助矩阵
services/context 围绕 Base/Context 提供一族响应方法,全部最终写到 ctx.Resp:
| 方法 | 文件 | 行为要点 |
|---|---|---|
ctx.HTML(status, tpl, data) | context_response.go | 渲染模板;渲染失败转 ServerError(500);500 模板本身失败时退化为纯文本 |
ctx.Redirect(location) | base.go | 302;外部重定向会删除 session cookie(防 Safari 跨站登出 bug);fetch-action 请求返回 JSON redirect |
ctx.JSON(status, obj) | base.go | 标准 JSON 输出 |
ctx.PlainText(status, str) | base.go | text/plain; charset=utf-8 + X-Content-Type-Options: nosniff |
ctx.NotFound() | context_response.go | 按 Accept 决定 HTML 404 页还是纯文本 |
ctx.ServerError(status, msg) | context_response.go | err == ErrNotExist 时降级为 404,否则 500 |
ctx.NotFoundOrServerError(err) | context_response.go | 单一入口:NotExist → NotFound,其他 → ServerError |
ctx.HTTPError(status, msg) | base.go | 状态 + 带提示的错误消息 |
ctx.JSONRedirect/JSONOK/JSONErrorAuto/JSONError... | context.go | fetch-action 风格的 JSON 辅助,JSONErrorAuto 按错误类型自动选状态码与本地化消息 |
ctx.APIError* | api.go | API 侧同族,输出 Gitea REST 错误信封 |
JSONErrorAuto 值得看一眼,它体现了"错误分层"的处理哲学:
func (ctx *Context) JSONErrorAuto(err error) {
if errTr := util.ErrorAsTranslatable(err); errTr != nil {
msg := errTr.Translate(ctx.Locale)
ctx.JSON(http.StatusBadRequest, buildJsonErrorMap(msg))
return
}
errMsg, httpCode := util.ErrorUnwrapForUser(err)
if errMsg != "" {
ctx.JSON(httpCode, buildJsonErrorMap(errMsg))
return
}
logLevel := util.Iif(httplib.IsClientOrNetworkError(ctx, err), log.DEBUG, log.ERROR)
log.Log(1, logLevel, "JSONErrorAuto: server internal error: %v", err)
userErrorMsg := ctx.buildUserErrorMessage("internal server error", err)
ctx.JSON(http.StatusInternalServerError, buildJsonErrorMap(userErrorMsg))
}
三档:可翻译错误 → 400 + 本地化消息;可对用户展示的错误 → 按错误自带状态码;否则视为内部错误 → 记日志(客户端/网络类错误降为 DEBUG,避免把断连当故障刷屏)→ 500 + 通用文案。用户永远看不到原始 error 串,细节只进日志。
3.4.2 NotFound 与 ServerError 的内容协商
context_response.go 中,错误响应按 Accept 头选择呈现形式——浏览器要 HTML 就给错误页,curl/脚本要文本就给一行字:
func (ctx *Context) notFoundInternal(...) // Accept: text/html → 渲染 404 模板,否则纯文本
func (ctx *Context) ServerError(status int, msg string) {
// err == ErrNotExist → notFoundInternal ...
}
(完整实现见 services/context/context_response.go,核心判据是 strings.Contains(req.Header.Get("Accept"), "text/html"),与 common.renderServerErrorPage 同构。)这个设计让同一个 URL 在浏览器和 wget 下各得其所:人拿到可导航的错误页,程序拿到无噪声的纯文本。
服务端错误页的渲染在 routers/common/errpage.go:
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)
}
注意三个防御性设计:先 SetCacheControlInHeader 禁止缓存错误页;整页先渲染进 outBuf 再写出,避免模板渲染到一半失败留下半截 HTML(HTTP 状态已定,宁可写纯文本兜底);兜底文案直接指向 Gitea issue tracker。
3.4.3 panic 恢复链
panic 的捕获点在 routers/common/middleware.go 的 RequestContextHandler(协议层中间件,全局生效):
defer func() {
if recovered := recover(); recovered != nil {
renderPanicErrorPage(respWriter, req, recovered) // it should never panic, and it handles the stack trace internally
}
}()
renderPanicErrorPage(routers/common/errpage.go):
// 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 || setting.IsInTesting || (user != nil && user.IsAdmin) {
plainMsg = "PANIC: " + combinedErr.Error()
ctxData["ErrorMsg"] = plainMsg
}
renderServerErrorPage(w, req, http.StatusInternalServerError, tplStatus500, ctxData, plainMsg)
}
要点:
- 注释解释了为何不碰
ctx:panic 可能发生在Contexter建立 Web context 之前,此时cache.GetContextWithData之类再炸一次,原始堆栈就永远丢了。所以只从reqctx的轻量数据仓库取签名用户,且类型断言带, _忽略失败。 - 堆栈泄露边界:仅当
!IsProd(开发模式)、IsInTesting或当前用户是管理员时才把PANIC: ...完整堆栈给到响应;生产环境普通用户只看到 "Internal Server Error",完整堆栈在服务端日志。管理员能看到,是为了让站长第一现场排障。 - 内层
defer recover保证"渲染 500 页时再次 panic"不会逃逸——函数名注释 "it never panics" 是契约。
routing.UpdatePanicError 把 panic 信息挂到请求记录上,路由日志(3.9)会把它带进最终的请求完成日志。
3.4.4 错误 → 状态码的映射约定
综合以上,Gitea 没有一个集中的 errHandler 中间件,而是分散约定 + 入口收敛:
flowchart LR
E["handler 返回 error"] --> N["ctx.NotFoundOrServerError(err)"]
E --> A["ctx.JSONErrorAuto(err)"]
E --> H["ctx.HTTPError(status, msg)"]
N -->|errors.Is ErrNotExist| F1["404 + Accept 协商"]
N -->|其他| F2["500 + status/500 模板"]
A -->|可翻译| G1["400 本地化消息"]
A -->|可展示| G2["error 自带状态码"]
A -->|内部错误| G3["500 通用文案 + 日志"]
H --> F3["指定状态码 + 提示消息"]
F2 --> P["renderServerErrorPage (Accept 协商, 禁缓存, outBuf 兜底)"]
- 404/405:路由未匹配由 chi 产生,最终落进
NormalRoutes()的NotFound(http.NotFound,纯文本404 page not found);而"业务上的不存在"(仓库不存在、用户不存在)走ctx.NotFound(),给出完整 404 页面。两层 404 的差别在于:前者说明 URL 打错了,后者说明资源不存在但站点还在。 - 405:
optionsCorsHandler对无 CORS 处理的 OPTIONS 显式返回http.StatusMethodNotAllowed;chi 自身对路径匹配但方法不匹配的路由也返回 405。 - 500:handler panic →
renderPanicErrorPage;模板渲染失败 →ctx.HTML内部转ServerError;显式ctx.ServerError。 - API 侧统一为
ctx.APIError*输出的 Gitea 错误信封({"message": "...", "url": "..."}),见services/context/api.go,第 9 章展开。
另一个隐蔽但重要的行为在 base.go 的 Redirect:对外部站点的 302 会主动删掉 session cookie。注释说明动机是 Safari 的 bug——用户被跳去外站再回来时 cookie 可能被清,索性在出站时干净利落地处理,避免"看起来像被登出"的诡异状态。同函数还区分 fetch-action 请求(返回 JSON 而非 302),与 3.3.5 的 GetFetchActionForm 呼应。
3.5 中间件链全景:从 TCP 到 handler 的完整顺序
3.5.1 精确顺序表
把 NormalRoutes() → web_routers.Routes() → registerWebRoutes() 三处的注册按执行次序展开("?"表示受配置开关控制):
| # | 阶段 | 中间件 | 定义位置 | 职责 |
|---|---|---|---|---|
| 1 | BeforeRouting | ChiRoutePathHandler | routers/common/middleware.go | 让 chi 用 EscapedPath/RawPath 做路由匹配,%2f 不被提前解码 |
| 2 | BeforeRouting | RequestContextHandler | 同上 | 建 reqctx 请求上下文、WrapResponseWriter、gtprof span、全局 panic 恢复、cache 上下文 |
| 3 | BeforeRouting | SecurityHeadersHandler | 同上 | X-Content-Type-Options、X-Frame-Options |
| 4 | BeforeRouting | ForwardedHeadersHandler? | 同上(chi-middleware/proxy) | 反代场景下按可信代理白名单改写 RemoteAddr/X-Forwarded-* |
| 5 | BeforeRouting | routing.NewRequestInfoHandler | modules/web/routing/requestinfo.go | 建请求记录(FuncInfo 栈、慢请求 3s 检测、requestCounter) |
| 6 | BeforeRouting? | context.AccessLogger | services/context/access_log.go | 访问日志(setting.IsAccessLogEnabled() 时) |
| 7 | BeforeRouting? | public.ViteDevMiddleware | modules/public/vitedev.go | 开发模式把前端请求反代到 Vite dev server |
| 8 | BeforeRouting | chi_middleware.GetHead | routers/web/web.go | HEAD 未定义时转 GET(RFC 9110 §9.3.2) |
| 9 | AfterRouting(根) | MaintenanceModeHandler | routers/common/maintenancemode.go | 维护模式拦截(白名单前缀放行) |
| 10 | AfterRouting(webRoutes) | gzhttp wrapper? | routers/web/web.go | gzip 压缩(MinSize 1400,级别 6,随机 jitter) |
| 11 | AfterRouting | MustInitSessioner | routers/common/middleware.go | 会话(gitea.com/go-chi/session) |
| 12 | AfterRouting | context.Contexter | services/context/context.go | 构造 *Base/*Context、Flash、locale 数据、Cache-Control、multipart 预解析 |
| 13 | AfterRouting | common.AuditOrigin(OriginUI) | routers/common/audit.go | 标记请求来源为 UI,供审计使用 |
| 14 | AfterRouting | webAuth.MiddlewareHandler | routers/web/web.go | Web 认证组(OAuth2→DeployToken→Basic→ReverseProxy→Session→SSPI) |
| 15 | AfterRouting | goGet | routers/web/goget.go | ?go-get=1 返回带 <meta> 的 HTML(go import 协议) |
| 16 | AfterRouting | common.PageGlobalData | routers/common/pagetmpl.go | 懒加载未读通知数、秒表数据(sync.OnceValue) |
| 17 | AfterRouting | common.BlockExpensive? | routers/common/blockexpensive.go | 匿名用户的"昂贵路由"重定向到登录 |
| 18 | AfterRouting | common.QoS | routers/common/qos.go | codel 主动队列管理,高负载时丢弃低优先级请求 |
| 19 | AfterRouting | RouterMockPoint(BeforeWebRoutes) | modules/web/routemock.go | 测试注入点 |
| 20 | 路由/分组 | 各 Group 中间件 | routers/web/web.go | reqSignIn、context.RepoAssignment、web.Bind[...]、feature gate 等 |
第 20 行以下就是 3.6 节的分组明细。注意 1–7 是 NormalRoutes() 的 BeforeRouting,8 是 web.Routes() 自己的 BeforeRouting——同一个 chi.Use 段内按子 Router Mount 顺序衔接;9 属于根 Router 的 AfterRouting,10–19 是 webRoutes.AfterRouting(mid...),经 routes.Mount("", webRoutes) 挂入。
3.5.2 逐项解读:协议层五件套
ChiRoutePathHandler 解决的是"转义路径参与匹配"问题:
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)
})
}
}
如果让 chi 拿解码后的 URL.Path 匹配,仓库名里的 %2f 会被 Go 标准库解成 /,/repo/a%2Fb 就会被误拆成三段。强制 chi 用转义形态,{reponame} 才能拿到字面量 a/b。它必须排第一,因为后面的中间件(包括 BlockExpensive 读 RoutePattern())都建立在这次匹配之上。
RequestContextHandler 是请求生命周期的"根":
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)
})
}
}
四件事按 defer 顺序倒推其生命周期:
reqctx.NewRequestContext创建requestDataStore(3.5.3),返回finished——请求结束时执行cleanUp()(关闭 AddCloser 注册的资源)并结束 process 记录;- gtprof span 包住整个请求,span 属性在结束时补上最终匹配的 RoutePattern(此时才知道);
recover→renderPanicErrorPage(3.4.3)——这是整个 Gitea 唯一的全局 panic 兜底,位于链的最外层之一,所以无论哪层中间件还是 handler panic,用户都能拿到 500 页;cache.WithCacheContext把缓存句柄挂进 ctx,httplib.RequestWithContext保证后续从req.Context()取到的是RequestContext(含数据仓库)。
注释还澄清了一个容易搞混的点:这里的 respWriter 与 ctx.Base.Resp 可能不是同一个对象——10 位的 gzip wrapper 夹在中间时,外层统计的是压缩后字节数。
ForwardedHeadersHandler 是条件装配:只有配置了 ReverseProxyLimit > 0 且有 ReverseProxyTrustedProxies 才挂,用 chi-middleware/proxy 且 ClearTrustedProxies() 后逐个 AddTrustedProxy/AddTrustedNetwork——默认不信任任何代理,避免伪造 X-Forwarded-For 绕过封禁。
SecurityHeadersHandler 按 setting.Security.* 写两个头,值为 "unset" 则跳过——管理员可在配置里关掉或自定义(例如内嵌 iframe 场景改 X-Frame-Options)。
AccessLogger(services/context/access_log.go)在链外层计时,链结束才记录:
func AccessLogger() func(http.Handler) http.Handler {
recorder := newAccessLogRecorder()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
respWriter := WrapResponseWriter(w)
next.ServeHTTP(respWriter, req)
recorder.record(start, respWriter, req)
})
}
}
日志模板来自 setting.Log.AccessLogTemplate,身份字段取自 middleware.GetContextData(req.Context()) 的 ContextDataKeySignedUser——日志中间件虽然在 auth 之前注册,但它在 auth 之后才读数据(记录动作发生在链尾),这是"包装式中间件"的典型时序。RequestID 从可配置的头部(如 X-Request-ID)取,isSafeRequestID 过滤非可打印字符、超 40 字节截断——防日志注入。
3.5.3 reqctx:请求级数据仓库
中间件之间传递数据不走函数参数,走 modules/reqctx:
// RequestDataStore is a short-lived context-related object that is used to store request-specific data.
type RequestDataStore interface {
GetData() ContextData
SetContextValue(k, v any)
GetContextValue(key any) any
AddCleanUp(f func())
AddCloser(c io.Closer)
}
三条通道,语义各异:
| 通道 | API | 线程安全 | 生命周期 | 典型内容 |
|---|---|---|---|---|
ContextData(map[string]any) | GetData()["IsSigned"]=true | 否(源码注释明确要求调用方保证) | 请求内 | 模板数据 ctx.Data、签名用户、表单 |
| typed values | SetContextValue/GetContextValue | 是(RWMutex) | 请求内,finished 时清空 | pre 中间件打的 auth 许可标记(3.6.2) |
| cleanups | AddCleanUp/AddCloser | 是 | 请求结束统一执行 | 打开的临时文件、流 |
NewRequestContext 把 store 挂进标准 context.Context,requestContext.Value 的实现是先查 store 再查父 ctx——这让 chi.RouteContext(req.Context())(走父链)与 Gitea 自己的键(走 store)互不干扰。
ContextData 不加锁是个刻意的权衡:它就是 ctx.Data,模板渲染前的写入都发生在同一条 goroutine 上,加锁是纯开销;但这也意味着不要在异步 goroutine 里写 ctx.Data(除非自己同步)。
数据的消费者遍布全链:AuthShared 写 IsSigned/SignedUserID,Bind 写 ErrorMsg,Contexter 合并 CommonTemplateContextData,访问日志读 ContextDataKeySignedUser——一张 map 串起了认证、绑定、渲染、审计四个子系统。
3.5.4 session:MustInitSessioner
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
}
两点值得记录:
- TODO 注释是真实存在的设计缺陷记录:chi-session 用 gob 编码
Set类型存 session,重启后第一次Get会因类型未注册而解码失败——所以每个往 session 里塞自定义结构的包必须在启动期gob.Register(modules/session/key.go定义的 KeyUID、KeyImpersonatorData 等就是这类键的规范清单)。阅读这段注释能理解 Gitea 启动序列里看似多余的注册调用。 IgnoreReleaseForWebSocket: true——session 在请求结束时才写回存储;WebSocket 长连接没有"请求结束",如果正常 release 会在连接期间锁住 session,因此显式忽略,注释同时预告了未来 websocket handler 要自己管理同步。
Session 放在 11 位(gzip 之后、Contexter 之前)的顺序理由:压缩不应作用于 session 写回产生的响应头变更;而 Contexter 需要 session.GetContextSession(req) 拿到 session 对象来建 Web context。
3.5.5 Contexter:Web 上下文的装配线
services/context/context.go 的 Contexter() 是 Web 侧最"厚"的一层(完整代码见 3.4.1 引用处,这里列其动作序列):
NewBaseContext(resp, req)——内部panic若 Base 已存在(防止双重 Contexter),注入Locale(经middleware.Locale)与translation.ContextKey;NewWebContext——构造Context(Flash、Link、空 Repo/Org、TemplateContext),并SetContextValue(WebContextKey, ctx)(供 3.3.1 的注册 provider 取用);- 合并
CommonTemplateContextData,设置CurrentURL/Link/PageData; - Flash cookie:读上一请求遗留的 flash 消息进
ctx.Data["Flash"];注册ctx.Resp.Before(...)回调,在响应写出时把本次新 flash 写回 cookie(或清除旧的)——Before钩子是WrapResponseWriter提供的"响应前"动作; - multipart 预解析(带 FIXME,见下);
httpcache.SetCacheControlInHeader(...)给 HTML 响应打默认缓存头;SystemConfig、双因素提示、AllLangs等模板数据。
第 5 步的注释是理解历史包袱的窗口:
// FIXME: GLOBAL-PARSE-FORM: this ParseMultipartForm was used for parsing the csrf token from multipart/form-data
// We have dropped the csrf token, so ideally this global ParseMultipartForm should be removed.
// When removing this, we need to avoid regressions in the handler functions because Golang's http framework is quite fragile
// and developers sometimes need to manually parse the form before accessing some values.
if ctx.Req.Method == http.MethodPost && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") {
if !ctx.ParseMultipartForm() {
return
}
}
它同时回答了两个问题:Gitea 28.x 已经没有 CSRF token 中间件(原 gorilla/csrf 方案被弃用,见 3.6.3),但为解析 CSRF token 而加的全局 ParseMultipartForm 仍在——因为删掉它会让"没手动 parse 就读 form"的存量 handler 出回归,Go 的 Request.FormValue 惰性解析在这种地方非常脆弱。一个 FIXME 挂着两个系统(旧 CSRF 的遗产与表单解析的现状),是"改一半"的真实工程状态。
3.5.6 资源治理三件套:BlockExpensive、QoS、MaintenanceMode
BlockExpensive(routers/common/blockexpensive.go)把匿名用户的重活挡在门外:
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)
})
}
}
isRoutePathExpensive 维护一张基于 RoutePattern 的清单:archive/blame/commit/compare/graph/media/raw/src 等代码页、issues/pulls 列表、wiki、activity——都是要开 git 仓库或重查询的路由。判据用 chi.RouteContext(...).RoutePattern() 而非原始 URL,所以 /alice/repo/blame/main/x.go 与清单里的 /{username}/{reponame}/blame/ 前缀匹配即可,参数值不参与判断。登录页与注册页(/user/、/login/ 前缀)显式豁免——否则匿名用户连登录入口都被重定向,形成死循环。
该中间件排在 17 位(Contexter+auth 之后)是功能前提:determineRequestPriority 要读 ContextDataKeySignedUser 判断是否登录,这个键由 auth 链写入。若把它放到 auth 之前,所有人都会被当成匿名。
QoS(routers/common/qos.go)是运行时过载保护:基于 bohde/codel 主动队列管理,按路由类别赋优先级(-10 低、0 普通、10 高),队列延迟超标时丢弃低优先级请求并渲染 503 模板;被 routing.MarkLongPolling() 标记的长轮询请求(如 /-/ws)立即 Release,不占队列配额——否则大量长连接会把 codel 的延迟估计拖爆,殃及普通页面。挂载顺序在 BlockExpensive 之后(18 位):先免费挡掉可挡的匿名重活,再对剩下的全量做排队治理。
MaintenanceModeHandler 是根 Router 的第一个 AfterRouting 中间件(9 位),实现极简但白名单设计考究:
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",
)
...
if maintenanceMode.IsActive() && !isMaintenanceModeAllowedRequest(req) {
renderServiceUnavailable(resp, req)
return
}
next.ServeHTTP(resp, req)
}
白名单的每一段都有注释说明用途:/user/ 让管理员能登录进来关维护模式、/captcha/ 是登录页的验证码依赖、/api/internal/ 保证本地 git/hook 进程在维护期间照常工作(仓库推送不该被维护页挡住)、/-/ 覆盖管理页与 fetch-redirect、/api/healthz 精确放行让负载均衡探针拿到真实状态(被维护 ≠ 不健康)。维护状态从 setting.Config().Instance.MaintenanceMode.Value(ctx) 读——动态配置,改配置即生效无需重启。
放在链最前(9 位)意味着非白名单请求连 session、Contexter、认证都不必初始化——维护期间最小化开销,响应也只是 503 模板。代价是维护页拿不到用户态(谁在看维护页并不重要),这是刻意的取舍。
3.5.7 locale、theme 与其他横切
locale 不是独立中间件,而是嵌在 NewBaseContext 的构造路径里:middleware.Locale(resp, req)(modules/web/middleware/locale.go)按三级优先级解析——
// 1. Check URL arguments.
lang := req.URL.Query().Get("lang")
// 2. Get language information from cookies.
ck, _ := req.Cookie("lang")
// 3. Get language information from 'Accept-Language'.
tags := parseAcceptLanguage(req.Header.Get("Accept-Language"))
?lang= 显式指定时回写 cookie(SetLocaleCookie(resp, lang, 1<<31-1),永久),下次访问延续。安全细节在文件头部注释里:language.ParseAcceptLanguage 对超长畸形输入是二次方复杂度,且内置防护只数 - 分隔符、扫描器却把 _ 当别名——攻击者可以用一堆 _ 绕过内置防护烧 CPU。于是 maxAcceptLanguageLen = 200 先截断再解析,且"只用最高优先级的几个语言,截断是安全的"。一个 3 行的常量,背后是一次真实的 DoS 收敛。
登录成功后 auth.handleSignInNonInteractive 还会用用户的语言偏好覆盖 locale cookie——个人设置优先于浏览器猜测,这个优先级跃迁发生在认证阶段而非 locale 阶段。
theme 同样不是中间件:渲染期由 services/context/context_template.go 的 CurrentWebTheme() 从 cookie gitea_theme 读取(无 cookie 或非法值回落默认主题);写入走业务路由 POST /-/web-theme/apply(routers/web/web.go:540,挂了 crossOriginProtect)与 GET /-/web-theme/list。也就是说主题是"数据 + 渲染期读取",不占用中间件链位置。
zip/压缩 也不存在名为 zip 的中间件,实际有三处压缩设施:
gzhttp.NewWrapper(...)(10 位):HTTP 传输层 gzip,GzipMinSize=1400(小于阈值不压,避免小响应因压缩头反而变慢)、级别 6、RandomJitter(32, ...)——注释引用 klauspost 文档说明 jitter 是 BREACH 缓解措施(防攻击者通过压缩比推测响应内容);- bindata 预压缩:构建期把静态资源同时生成
.gz,modules/public直出时按Accept-Encoding选 gzip 版本,零运行时 CPU(3.7.2); common.GzipMinSize之外,静态资源路由本身不经过 mid 链里的 session 等(见 3.7.1 的"免 session 直挂")。
goGet(15 位)针对 ?go-get=1 的 GET 返回含 <meta name="go-import"> 的 HTML,供 go get 解析仓库根——它只需要在认证之后(私有仓库要校验)、页面数据之前,位置恰到好处。
PageGlobalData(16 位)用 sync.OnceValue 懒加载未读通知数与秒表:模板需要才算,算一次全请求复用;它是"每页都要但不是每页都用"的数据的典型处理——宁可挂在链上做 once,也不让每个 handler 各自调用。
3.5.8 一条 GET 请求的完整时间线
以登录用户访问 /alice/repo/src/branch/main 为例(压缩、访问日志、BlockExpensive 均启用):
chi mux
└ BeforeRouting: ChiRoutePath → RequestContext(建reqctx/panic兜底/span) → SecurityHeaders
→ ForwardedHeaders → RequestInfo → AccessLogger → ViteDev(开发模式) → GetHead
└ 匹配路由树 → 命中 registerWebRoutes 中 "repo code" Group
└ AfterRouting-normal 段(按 3.5.1 表 9→19):
MaintenanceMode → gzip wrapper → Sessioner → Contexter(Base/Context/Flash/locale)
→ AuditOrigin → webAuth(读 session → ctx.Doer) → goGet → PageGlobalData
→ BlockExpensive(RoutePattern 判定"code 相关",已登录放行) → QoS → MockPoint
└ Group/路由参数段:
optSignIn(verifyAuthWithOptionsWeb → CheckSignedInUser → CrossOriginProtection)
→ context.RepoAssignment(解析 owner/repo → 权限 → Repo 数据)
→ handler: repo.NewCodeCtx… 反射分发
└ handler 写出 HTML → hasResponseBeenWritten=true → 不再 next
└ defer 链回卷: RecordFuncInfo(记耗时) → span.End(RoutePattern) → reqctx finished(cleanUp)
└ AccessLogger.record: 状态码/字节数/身份 → access 日志
未匹配的 URL 则在第 4 步之后直接进入根 Router 的 NotFound(带上 9–19 的 AfterRouting 中间件),最终输出 404 page not found。维护模式下,非白名单请求在第 9 步就被拦下,10 以后全部不执行。
3.6 认证体系:三套 auth group 与跨站防线
3.6.1 services/auth 的 Method 接口与 Group 试错循环
所有认证方式实现同一接口(services/auth/interface.go):
type Method interface {
Name() string
Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error)
}
返回 (user, nil) 表示认证成功,(nil, nil) 表示"此方式不适用,下一个",(nil, err) 表示"试了但失败"。Group.Verify(services/auth/group.go)把它们串成试错链:
func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
// Try to sign in with each of the enabled plugins
var retErr error
for _, m := range b.methods {
user, err := m.Verify(req, w, store, sess)
if err != nil {
if retErr == nil {
retErr = err
}
// Try other methods if this one failed.
// Some methods may share the same protocol to detect if they are matched.
// For example, OAuth2 and conan.Auth both read token from "Authorization: Bearer <token>" header,
// If OAuth2 returns error, we should give conan.Auth a chance to try.
continue
}
// If any method returns a user, we can stop trying.
// Return the user and ignore any error returned by previous methods.
if user != nil {
if store.GetData()["AuthedMethod"] == nil {
store.GetData()["AuthedMethod"] = m.Name()
}
return user, nil
}
}
// If no method returns a user, return the error returned by the first method.
return nil, retErr
}
三条规则构成其语义:
- 失败不终止——注释解释了为什么:多个方法共用同一协议线索(
Authorization: Bearer既可能是 OAuth2 token 也可能是其他 token),OAuth2 报错时要给后面的尝试者机会; - 首个成功即停,且记录
AuthedMethod(只记第一个)——AuthShared用它判断是否 Basic 认证(影响是否展示"密码认证"相关 UI),API 层checkDeprecatedAuthMethods等也依赖; - 全失败返回第一个错误——保留"最可能的原因"给用户(比如格式错误的 Bearer token 比"没有 session"更有信息量)。
common.AuthShared(routers/common/auth.go)是三套路由共用的认证出口:
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
}
注意**管理员模拟登录(impersonation)**的处理:session 里存着 impersonator 的 uid 时解析出对象放进 ContextDataKeyImpersonator;解析失败只记日志、不打断请求——注释给了理由:"session 依然可用,但审计事件绝不能悄悄丢掉背后的管理员"。模拟态既影响 UI(显示"以 XX 身份"),也必须进入审计链。
3.6.2 Web 侧:PreMiddlewareProvider 打标 + MiddlewareHandler 组包
routers/web/web.go 的 newWebAuthMiddleware() 用一个巧妙的双段结构解决"不同路由允许不同认证方式":
func newWebAuthMiddleware() *AuthMiddleware {
type keyAllowOAuth2 struct{}
type keyAllowBasic struct{}
type keyAllowDeployToken struct{}
webAuth := &AuthMiddleware{}
middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dataStore := reqctx.GetRequestDataStore(r.Context())
dataStore.SetContextValue(key, val)
next.ServeHTTP(w, r)
})
}
}
webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true)
webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true)
webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true)
enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext())
webAuth.MiddlewareHandler = func(ctx *context.Context) {
allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true
allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true
allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true
group := auth_service.NewGroup()
// Most auth methods should ignore the user id stored in the session.
// If the auth succeeds, it must use the user id from the auth method to make sure the new login succeeds.
if allowOAuth2 {
group.Add(&auth_service.OAuth2{})
}
if allowDeployToken {
group.Add(&auth_service.DeployToken{}) // before Basic, which would try the token as a password
}
if allowBasic {
group.Add(&auth_service.Basic{})
}
// Sessionless means the route's auth can be done without web ui, then it doesn't need to create a session
// For example: accessing git via http, access rss feeds, downloading attachments, etc
isSessionless := allowOAuth2 || allowBasic || allowDeployToken
if setting.Service.EnableReverseProxyAuth {
// reverse-proxy should before Session, otherwise the header will be ignored if user has login
group.Add(&auth_service.ReverseProxy{CreateSession: !isSessionless})
}
// The Session plugin will skip authentication for users that have already signed in.
group.Add(&auth_service.Session{})
if enableSSPI {
// it MUST be the last, see the comment of SSPI
group.Add(&auth_service.SSPI{CreateSession: !isSessionless})
}
ar, err := common.AuthShared(ctx.Base, ctx.Session, group)
if err != nil {
log.Error("Failed to verify user: %v", err)
ctx.HTTPError(http.StatusUnauthorized, "Failed to authenticate user")
return
}
ctx.Doer = ar.Doer
ctx.IsSigned = ar.Doer != nil
ctx.IsBasicAuth = ar.IsBasicAuth
if ctx.Doer == nil {
// ensure the session uid is deleted
_ = ctx.Session.Delete(session.KeyUID)
}
}
return webAuth
}
两段结构的运行机制:
AllowBasic/AllowOAuth2/AllowDeployToken实现了types.PreMiddlewareProvider(因为它们是func(http.Handler) http.Handler形态)。按 3.2.3 的装配规则,pre 段排在 normal 段之前——所以注册在路由参数里的webAuth.AllowBasic会先于 normal 段的webAuth.MiddlewareHandler执行,把许可标记写进requestDataStore;MiddlewareHandler是反射型func(*context.Context)(normal 段),执行时读标记,动态组装本次请求的 auth group。
于是同一套代码支撑了路由级差异化配置,真实注册例(routers/web/web.go 的 git http 路由):
addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
git clone 走的路由允许 Basic(用户名密码/ PAT)、OAuth2 token、DeployToken;而普通页面路由只挂 reqSignIn(session)。"sessionless" 判定:只要允许任一 token 类认证就认为该路由可无会话访问(git http、RSS、附件下载都是这类),此时 ReverseProxy/SSPI 也不建 session——避免给每次 git fetch 都写会话存储。
顺序的三处注释级理由:
DeployToken before Basic——否则Basic会先把 deploy token 当密码去验,产生一次无谓的(且可能触发节流的)失败;ReverseProxy should before Session——反代注入的身份头若在 session 登录用户之后检查,永远轮不到它;SSPI MUST be the last——Windows SSPI 会对任何无凭据请求发起协商,放前面会吞掉后续方法的机会。
ctx.Doer == nil 时删除 session.KeyUID 是防御性清理:任何认证方法都没认出这个人(session 过期/被吊销),把残留 uid 删掉,避免下一次请求重复走失败路径。
3.6.3 verifyAuthWithOptionsWeb:授权检查与跨站防线
webAuth.MiddlewareHandler 只回答"你是谁","你能不能进"由 verifyAuthWithOptionsWeb(3.4 节已引部分代码)继续:
func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.Context) {
crossOriginProtection := http.NewCrossOriginProtection()
return func(ctx *context.Context) {
// Check prohibit login users.
if ctx.IsSigned {
check := common.CheckSignedInUser(ctx.Doer, ctx.Session)
if check.NeedActivateAccount { ... ctx.HTML(http.StatusOK, "user/auth/activate"); return }
else if check.LoginIsProhibited { ... ctx.HTML(http.StatusOK, "user/auth/prohibit_login"); return }
else if check.NeedChangePassword {
if ctx.Req.URL.Path != "/user/settings/change_password" {
if strings.HasPrefix(ctx.Req.UserAgent(), "git") {
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
return
}
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return
}
} else if ctx.Req.URL.Path == "/user/settings/change_password" {
// make sure that the form cannot be accessed by users who don't need this
ctx.Redirect(setting.AppSubURL + "/")
return
}
}
...
}
}
CheckSignedInUser(routers/common/auth.go)给出三个布尔:需要激活、被禁止登录、必须改密。三个分支各自的呈现很有代表性:
- 前两个渲染专用页面(200 而非 40x——用户没有错,是账号状态问题);
- 必须改密时:git 客户端返回 401 + 文本(git 不会执行 HTML 重定向,只会把页面当 clone 错误输出),浏览器则把原 URL 存进
RedirectToCookie后跳到改密页——改完还能跳回来; - 反向也堵上:不需要改密的用户访问改密页被送回首页。
接着是签名与跨站部分(同一函数后半段,3.4 已引):
// When a signed-in user visits a page that requires sign-out (e.g.: "/user/login"), redirect to home (or alternate location)
if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" {
ctx.RedirectToCurrentSite(ctx.FormString("redirect_to"))
return
}
if !options.SignOutRequired && !options.DisableCrossOriginProtection {
if err := crossOriginProtection.Check(ctx.Req); err != nil {
http.Error(ctx.Resp, err.Error(), http.StatusForbidden)
return
}
}
CSRF 现状:28.x 源码中没有 gorilla/csrf,也没有自研 token 校验——防线是 Go 标准库 net/http 的 http.NewCrossOriginProtection()(checkForbiddenOrigin 基于 Sec-Fetch-Site/Origin 判定跨站写请求并拒绝)。services/context/context.go 里 "We have dropped the csrf token" 的 FIXME(3.5.5 已引)是这一事实的直接书证。跨站保护挂在 verifyAuthWithOptionsWeb 内部而非全局中间件,是为了逐路由可关——optSignInFromAnyOrigin(3.5 引用过其定义)显式带 DisableCrossOriginProtection: true,注释解释:CORS 中间件已经处理了带 Sec-Fetch-Site 的浏览器预检,而 git clone 这类非浏览器客户端根本不发该头、也不是跨站请求。
crossOriginProtect 是同一函数的零选项实例,用在 /-/markup、/-/web-theme/apply 这类"任意登录用户都能 POST、但必须同站"的端点上;SignInRequired 分支把未登录者送去 middleware.RedirectLinkUserLogin(记住当前 URL 便于登录后跳回);AdminRequired 分支直接 403。
3.6.4 API 侧与 internal 侧:同族不同组
API(routers/api/v1/api.go)的挂法:
m.AfterRouting(context.APIContexter())
m.AfterRouting(common.AuditOrigin(audit_model.OriginAPI))
m.AfterRouting(checkDeprecatedAuthMethods)
// Get user from session if logged in.
m.AfterRouting(apiAuth(buildAuthGroup()))
m.AfterRouting(verifyAuthWithOptionsAPI(&common.VerifyOptions{
SignInRequired: setting.Service.RequireSignInViewStrict,
}))
认证组的构造是静态的,且与 Web 有四处实质差异:
func buildAuthGroup() *auth.Group {
group := auth.NewGroup(
&auth.OAuth2{},
&auth.HTTPSign{},
&auth.Basic{}, // FIXME: this should be removed once we don't allow basic auth in API
)
if setting.Service.EnableReverseProxyAuthAPI {
group.Add(&auth.ReverseProxy{}) // TODO: does it still make sense to support reverse proxy auth in API?
}
// others: API doesn't support SSPI auth because the caller should use token
return group
}
| 维度 | Web(newWebAuthMiddleware) | API(buildAuthGroup) | private(routers/private) |
|---|---|---|---|
| session | ✅ Session 方法 | ❌ 不传 session(AuthShared(ctx.Base, nil, authMethod)) | ❌ |
| HTTPSign(Signature 头/SSH 证书) | ❌ | ✅ | ❌ |
| Basic | 按路由 opt-in | ✅ 恒开(带 FIXME 想移除) | ❌ |
| DeployToken | 按路由 opt-in | (经 OAuth2/Basic 读 token 路径) | ❌ |
| ReverseProxy | 配置开关 | 单独开关 EnableReverseProxyAuthAPI | ❌ |
| SSPI | Windows 且启用,且必须最后 | ❌ 注释明说 "API doesn't support SSPI" | ❌ |
| 认证时机 | 逐路由组包(pre 打标) | 全局 AfterRouting 一次组包 | AfterRouting authInternal |
| 失败呈现 | HTML 401/重定向 | ctx.APIError(401, msg) | 内部协议错误 |
HTTPSign(services/auth/httpsign.go)只服务 API:读 Signature 头,支持 X-Ssh-Certificate(SSH 证书)与普通公钥签名——头不存在直接 (nil, nil) 跳过,所以放在组里第二位开销极小。
checkDeprecatedAuthMethods 是一个"观察型"中间件:发现 ?token=/?access_token= 查询参数就在响应上加 X-Gitea-Warning 头(提示将在 1.23 移除),不拦截——给存量客户端迁移窗口。这类"先警告后移除"的渐进策略在 API 演进中很常见。
verifyAuthWithOptionsAPI 与 Web 版行为对齐但输出全 JSON:未登录访问受限 API → {"message": "Only signed in user is allowed to call APIs."} 403;账号未激活/被禁/需改密 → 各自的 403 JSON(改密的还带绝对 URL,方便 CLI 用户复制)。它不做跨站保护——API 靠 token 而非 cookie,不存在 CSRF 面。
private(routers/private/internal.go)是 /api/internal,供 gitea serv/gitea hook 等本地进程调用:
// Routes() 的 AfterRouting 依次:
// PrivateContexter、authInternal、setRealIP、AuditOrigin(OriginSystem)
两处细节:setRealIP 的注释说明它替代了 chi 里已废弃且不安全的 middleware.RealIP(原实现无条件信任 X-Forwarded-For,在 internal 路由上同样危险,故自研并按配置信任);LFS 相关路由带 FIXME 标注待重构。SSH key 认证之所以不在这里:git-over-SSH 走的是 ssh-key-sign/gitea serv 命令行通道(sshd 调二进制,二进制再调 internal API),HTTP 层见到的已经是内部鉴权后的请求——这就是"SSH key 在 API 的差异"的完整答案:HTTP 侧根本没有 SSH key 方法,它在进程边界之外完成。
3.6.5 路由级授权中间件(分流到第 5/6 章)
认证之后是逐路由的授权中间件,Web 侧定义在 routers/web/web.go 的 registerWebRoutes 开头(341–350 行):
func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// middleware: required to be signed in or signed out
reqSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true})
reqSignOut := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignOutRequired: true})
// middleware: optional sign in (if signed in, use the user as doer, if not, no doer)
optSignInHome := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: false}) // site home doesn't need "require sign-in" protection
optSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
optExploreSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
// middleware: only apply CrossOriginProtection
crossOriginProtect := verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: false})
...
五个变体全部由同一个工厂产出,差异只在 VerifyOptions 字段:optSignIn 的门槛绑定 RequireSignInViewStrict 配置——站点可配置为"未登录也能浏览"或"全站强制登录",同一行代码两种模式。crossOriginProtect 注释直说 "only apply CrossOriginProtection":所有显式字段为零值,只因 DisableCrossOriginProtection: false 而执行跨站检查,用作纯 CSRF 用途的中间件。加上 reqExploreSignIn、reqAnyRepoUnitAccess、feature gate(webhooksEnabled、starsEnabled、lfsServerEnabled、feedEnabled、sitemapEnabled…——对应 3.8 表里的"条件注册"),以及两个重量级对象装载中间件:
context.RepoAssignment(services/context/repo.go:804):把{username}/{reponame}落成ctx.Repo。它已重构为显式的步骤序列,读起来就是一次仓库访问的完整检查清单:
// RepoAssignment returns a middleware to handle repository assignment
func RepoAssignment(ctx *Context) {
repoAssignmentPreCheck(ctx)
prepareData := repoAssignmentPrepareData(ctx)
funcs := []func(ctx *Context, data *repoAssignmentPrepareDataStruct){
repoAssignmentPrepareOwner,
repoAssignmentAutoRedirectWiki,
repoAssignmentPrepareRepo,
repoAssignmentLegacy,
repoAssignmentPrepareTemplateData,
repoAssignmentAutoRedirectNotReady,
repoAssignmentPrepareGitRepo,
repoAssignmentPrepareRepoTransfer,
repoAssignmentPrepareBranches,
repoAssignmentPreparePullRequests,
}
for _, f := range funcs {
f(ctx, prepareData)
if ctx.Written() {
return
}
}
}
每步执行后检查 ctx.Written()——任何一步写出响应(404、403、重定向)立即终止后续步骤,这正是 3.3.4 短路语义在业务层的呼应:RepoAssignment 自己就是一组反射型 handler 调用。owner 解析、仓库解析、git 目录装载、分支/PR 预取的内部逻辑属于第 5、6 章(仓库与组织权限模型)的主菜,本章只需记住它在链中的位置:optSignIn 之后、业务 handler 之前,且只在匹配 /{username}/{reponame} 系路由的 Group 上。
context.OrgAssignment同理服务/org与组织属主路由;context.UserAssignmentWeb服务/{username}单层路由;API 侧对应repoAssignment()/orgAssignment()(routers/api/v1/api.go)返回的闭包形态,语义相同、输出不同。
这里能看出 Gitea 中间件的分层哲学:session/auth 这类"全站一致"的放 AfterRouting 链;"这条路由需要谁"的放 Group 参数;"装载领域对象"的(Repo/Org/User Assignment)放 Group 参数靠前位置,让同组所有 handler 共享装载结果。
3.7 静态资源与缓存:assetfs、httpcache 与开发模式
3.7.1 静态路由注册:为什么免 session 直挂
回看 routers/web/web.go 的 Routes() 开头(3.5.1 已引原文),有一批路由注册在 mid 装配之前:
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD", "/assets/site-manifest.json", misc.SiteManifest)
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
routes.Methods("GET, HEAD", "/apple-touch-icon-precomposed.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png"))
这段代码的结构本身就是文档:mid 切片在这些行之后才被 append session/Contexter(var mid []any 声明于它们之前,296 行才 mid = append(mid, common.MustInitSessioner(), ...))。因此这批路由不建 session、不建 Context、不做认证——静态资源请求与用户状态完全无关,砍掉整条状态链是最大的性能优化。逐行注释也各有讲究:
HEAD /(misc.DummyOK)给健康检查用,注释明说"不需要过 gzip handler"——探针只关心状态码;/assets/*挂了三件:MarkLogLevelTrace(静态资源访问降为 TRACE,不刷访问日志)、AssetsCors()(跨域字体等资源的 CORS 头)、FileHandlerFunc()(真正的文件服务);- 两个 favicon 与 apple-touch-icon 用
misc.StaticRedirect指到/assets/img/...——兼容旧书签路径。
robots.txt、/captcha/*、/metrics、/-/actions/artifacts/... 也用 append(mid, ...) 的形式只带 gzip、不带 session——append(mid, handler) 创建新切片不改原 mid,这是 Go 切片惯用法,注释解释 artifacts 那条:"no session, the sandboxed frame sends no cookie"(沙箱 iframe 不发 cookie,建 session 毫无意义)。
avatars 的 avatarStorageHandler(routers/web/base.go)带 fallback 逻辑:找不到用户上传的头像就随机生成默认头像,并对响应打 ETag(默认头像可安全私有缓存——头像内容不随请求变化)。
3.7.2 assetfs:分层文件系统与打包/开发两态
静态文件的读取经 modules/public → modules/assetfs。核心是分层 FS(modules/assetfs/layered.go):
// LayeredFS is a layered asset file-system. It works like http.FileSystem, but it can have multiple layers.
// The first layer is the top layer, and it will be used first.
// If the file is not found in the top layer, it will be searched in the next layer.
type LayeredFS struct {
layers []*Layer
}
// Layered returns a new LayeredFS with the given layers. The first layer is the top layer.
func Layered(layers ...*Layer) *LayeredFS {
return &LayeredFS{layers: layers}
}
// Open opens the named file. The caller is responsible for closing the file.
func (l *LayeredFS) Open(name string) (fs.File, error) {
for _, layer := range l.layers {
f, err := layer.Open(name)
if err == nil || !os.IsNotExist(err) {
return f, err
}
}
return nil, fs.ErrNotExist
}
两种 Layer:Local(name, base, sub...) 从磁盘目录读(os.DirFS),Bindata(name, fs) 从编译进二进制的资产读。modules/public/public.go 组装时 custom 层在上、builtin 层在下——用户放一个同名文件到 custom/public/assets/ 就能覆盖打包资产,无需重编译。注意 Open 的判据是 err == nil || !os.IsNotExist(err):非"不存在"的错误(权限等)立即返回而不是继续下探,避免把权限故障伪装成 404。
ReadDir 做了跨层合并:同名文件取上层、隐藏文件(shouldInclude → util.IsCommonHiddenFileName)过滤、结果排序——保证覆盖后的目录列表语义一致。WatchLocalChanges 用 fsnotify 监听 Local 层目录、100ms 去抖后回调,支撑开发模式热重载。
构建态与开发态的切换由 build tag 与运行时探测共同完成:
| 场景 | 资产来源 | 机制 |
|---|---|---|
| 生产二进制(bindata) | //go:build bindata 编译进包 | public.AssetFS() 返回 Bindata 层;构建时预生成 .gz,服务时按 Accept-Encoding 直出 |
普通构建(!bindata) | static/ 目录 Local 层 | public_dynamic.go;WatchLocalChanges 可选 |
make watch 开发 | Vite dev server(watch-frontend)+ air(watch-backend) | ViteDevMiddleware 把 /assets/... 请求反代到 dev server 端口 |
Makefile 里 watch: @bash tools/watch.sh,watch-frontend 起 vite dev server,watch-backend 用 air 热编译后端——前端改一行即时生效、后端改完自动重启,这是 7 位 ViteDevMiddleware(仅 !setting.IsProd 挂载)存在的全部理由。
modules/public/vitedev.go 的运行时细节:Vite 启动后把端口写进 public/assets/.vite/dev-port 文件,中间件读该文件定位 dev server;IsViteDevRequest 判定哪些请求该转发;转发的响应带 X-Gitea-Vite-Dev-Server 头便于辨识。而 3.2.4 的 normalizeRequestPath 对 IsViteDevRequest 豁免子路径 404,正是因为 dev 请求的路径规则与业务路由不同。
3.7.3 FileHandlerFunc 与 SiteManifest:静态服务的边界条件
modules/public/public.go 的 FileHandlerFunc 是 /assets/* 的终点,实现只有几行但边界明确:
func FileHandlerFunc() http.HandlerFunc {
assetFS := AssetFS()
return func(resp http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
resp.WriteHeader(http.StatusMethodNotAllowed)
return
}
handleRequest(resp, req, http.FS(assetFS), req.URL.Path)
}
}
- 非 GET/HEAD 直接 405——静态资源没有写语义,注册处却允许 OPTIONS(配合
AssetsCors做跨域字体预检); handleRequest的错误分层:文件不存在 → 404(不记日志,404 是常态);存在但打不开 → 500 且记日志(权限/IO 故障是异常);目录 → 404(注释 "need to serve index file? (no at the moment)"——显式决定不做目录 index);servePublicAsset负责压缩与缓存(压缩见下文,缓存头见 3.7.4)。
bindata 模式的 gzip 直出就发生在 servePublicAsset 里:
encodings := parseAcceptEncoding(req.Header.Get("Accept-Encoding"))
fiEmbedded, _ := fi.(assetfs.EmbeddedFileInfo)
if encodings.Contains("gzip") && fiEmbedded != nil {
// try to provide gzip content directly from bindata
if gzipBytes, ok := fiEmbedded.GetGzipContent(); ok {
rdGzip := bytes.NewReader(gzipBytes)
// all gzipped static files (from bindata) are managed by Gitea, so we can make sure every file has the correct ext name
// then we can get the correct Content-Type, we do not need to do http.DetectContentType on the decompressed data
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Content-Encoding", "gzip")
http.ServeContent(w, req, fi.Name(), modtime, rdGzip)
return
}
}
http.ServeContent(w, req, fi.Name(), modtime, content)
三个注释级细节:parseAcceptEncoding 支持带 q 值的 Accept-Encoding 解析(gzip;q=1.0, *;q=0.5);预压缩文件跳过 http.DetectContentType(构建期已保证扩展名正确,省一次全文嗅探);gzip 字节直接喂 http.ServeContent——后者会基于 If-Range/modtime 做 206/304,压缩与否不破坏范围请求语义。
misc.SiteManifest(/assets/site-manifest.json)单独注册不走通配,其实现展示了"小文件 + 强缓存"的模板:
func SiteManifest(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
if httpcache.HandleGenericETagPublicCache(req, w, "", &setting.AppStartTime) {
return
}
if req.Method == http.MethodHead {
return
}
... // 构造 manifest JSON(name/start_url/icons)
}
以 setting.AppStartTime 为 ETag 种子做公有缓存条件请求:进程不变则 ETag 不变,浏览器二次访问直接 304(连 body 都不生成——HEAD 检查放在 ETag 之后也说明"条件命中优先于方法分派")。manifest 内容随配置(站点名、图标)而变,但这些在单进程生命周期内不变,进程重启即换 ETag——用启动时间当版本戳是无状态条件下最省的正确做法。
3.7.4 httpcache:条件请求与缓存头
modules/httpcache/httpcache.go 是所有"可缓存"响应的头政策中心,Contexter 与错误页渲染都调用它:
func SetCacheControlInHeader(header http.Header, opts *CacheControlOptions)
行为要点(据源码与其内注释):
- prod 与 dev 不同:生产环境给可缓存资源
public+ 有效期;开发环境配合X-Gitea-Debug头暴露决策细节,方便排查"为什么没缓存/为什么缓存了"; - 默认参数(
&httpcache.CacheControlOptions{})用于 HTML 页与错误页——语义是"不要缓存"(HTML 含用户态数据,缓存即串号); handleGenericETagTimeCache实现条件请求,注释标明遵循 RFC 9110:If-None-Match优先于If-Modified-Since——两者同时出现时必须先比 ETag(强校验器),只比日期会因时钟精度产生错误 304;checkIfNoneMatchIsValid校验客户端 ETag 是否仍有效,有效则直接304 Not Modified不带 body。
routers/common/serve.go 的 ServeBlob 则给仓库文件下载(raw/附件)另一套策略:ETag + 私有缓存(Cache-Control: private,因为仓库可见性是按用户的,共享代理不得缓存)。同一份"文件",站点静态资产可公缓存、仓库内容只能私缓存——差异完全来自"响应是否与身份相关"。
ETag 的生成对文件内容做哈希,路径变动/内容变动都会换值;配合 3.7.1 里 avatar 的 ETag,构成站点内一致的条件请求语义:浏览器二次访问几乎全走 304,带宽成本趋近于零。
3.8 路由注册全景表
3.8.1 Web 主要分组(routers/web/web.go 的 registerWebRoutes)
registerWebRoutes 全文约 1500 行,按前缀整理的分组地图("中间件"列只列该组的签名/装载关键件,Bind 略):
| 前缀 | 主要分组/路由 | 关键中间件 | 说明 |
|---|---|---|---|
/ | m.Get("/", optSignInHome, Home) | optSignInHome | 站点首页;sitemap、/.well-known(OIDC 发现、nodeinfo、webfinger、passkey 端点) |
/-/ | /-/markup、/-/web-theme/*、/-/admin 组 | reqSignIn、crossOriginProtect | 用户态辅助端点;/-/ws 挂 routing.MarkLongPolling() |
/explore | repos/users/organizations/code/topics | optExploreSignIn(+ feature gate) | 匿名可浏览的发现页 |
/issues、/pulls、/milestones | 跨仓搜索与个人列表 | reqSignIn + gate | |
/user、/user/settings | 登录/注册/双因素/webauthn/openid/设置各页 | reqSignIn/reqSignOut、web.Bind | 登录相关走 SignOut/SignIn 变体 |
/login/oauth | OAuth2 authorize/grant | optSignIn | |
/-/admin | 站点管理各页 | reqSignIn + AdminRequired(经 verify options) | |
/org | 组织页与设置 | reqSignIn、context.OrgAssignment | 两处 /org Group(955、962 行)分别服务浏览与设置 |
/repo | 仓库创建/模板/fork | reqSignIn | |
/{username}/- | 用户级项目板 | context.UserAssignmentWeb | |
/{username}/{reponame} | 仓库主域:settings、issues、pulls、src、raw、media、wiki、activity、actions、projects、releases、tags… | 每组各自 optSignIn/reqSignIn + context.RepoAssignment + RequireUnitReader 等 | 3.8.2 细看 |
/{username}/{reponame}/settings | 仓库设置(协作者、webhooks、分支、标签、转移…) | reqSignIn + RepoAssignment + 写权限检查 | webhooks 子组 454–520 行 |
/notifications | 通知中心 | reqSignIn | |
| git http | addOwnerRepoGitHTTPRouters 系 | AllowBasic/OAuth2/DeployToken + optSignInFromAnyOrigin + UserAssignmentWeb | smart HTTP 协议端点 |
| LFS | LFS 相关 | 同上(token 友好) | |
/api/swagger | swagger UI 页面 | append(mid, ...)(带完整 mid 但不在 API 组内) | 注释解释"route is here but not in API routes because it renders a web page" |
| 未匹配 | m.NotFound(ctx.NotFound) | 经 AfterRouting 链 | 业务 404 页面(与根 Router 的 http.NotFound 纯文本 404 不同) |
feature gate 以中间件形式注册(m.Get("/sitemap.xml", sitemapEnabled, optExploreSignIn, HomeSitemap) 这种三中间件形态),gate 内部读配置,未启用时直接写 404——好处是路由表恒定(便于测试与文档生成),开关只影响执行。
3.8.2 仓库主域的分组结构
/{username}/{reponame} 被切成约 15 个并列 Group(1285–1769 行),切分依据是所需权限单元:
m.Group("/{username}/{reponame}/{type:issues}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests))
m.Group("/{username}/{reponame}/{type:pulls}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitPullsReader)
m.Group("/{username}/{reponame}", func() { // repo code (at least "code reader")
...
三个手法值得注意:
{type:issues}chi 正则约束——一个占位符同时匹配/issues与/pulls字面量,避免注册两条重复路由;- Group 级中间件参数——
RepoAssignment+RequireUnitReader写在Group(pattern, fn, middlewares...)的可变参数里,组内所有路由共享,不用逐条重复; - 注释即权限说明——
// repo code (at least "code reader")、// edit issues, pulls, labels, milestones, etc直接标明该组的单元权限下限,路由表读起来就是权限矩阵(细化到 unit 级别的判定见第 5、6 章)。
addIssuesPullsViewRoutes 这类共享函数在多个 Group 复用注册(issues 与 pulls 页面结构同构),是"注册代码 DRY"的典型。
3.8.3 API、internal、packages、actions 分组
| Mount 点 | 定义处 | 分组概览 |
|---|---|---|
/api/v1 | routers/api/v1/api.go Routes()(999 行起) | /repos、/users、/orgs、/admin、/notifications、/markdown、/git、/settings、/actions(secrets/variables/runners/runs)等;每端点链 = reqToken()/req*权限 + bind[T] + handler |
/api/internal | routers/private/internal.go | PrivateContexter + authInternal + setRealIP;供本地进程(gitea serv、hook、LFS 内部转发) |
/api/packages、/v2 | routers/packages/(Packages.Enabled) | 常规包管理 API 与 OCI registry(/v2 必须根路径) |
/api/actions、/api/actions_pipeline、ARTIFACT_V4 | routers/actions/(Actions.Enabled) | runner 通信与 artifact 服务 |
/-/fetch-redirect | common.FetchRedirectDelegate | 根级单 POST,校验目标为当前站点防开放重定向 |
API 分组的注册形态与 Web 一致(m.Group + Combo + 中间件参数),差异在中间件的"词汇表":Web 组用 reqSignIn/context.RepoAssignment,API 组用 reqToken/reqSiteAdmin/reqRepoReader(unitType)/orgAssignment()/repoAssignment()——同一领域概念(读者/写者/属主)在两侧各自实现,输出分别是 HTML 403 页与 JSON 403。reqToken() 要求携带 token(区分"匿名浏览公开数据"与"以身份调用"),checkTokenPublicOnly 系列进一步限制 token 的 scope——scope 模型第 9 章展开。
3.8.4 安装路由:独立的第二套路由
未安装时 cmd/web.go 的 serveInstall() 调 install.Routes()(routers/install/routes.go),全文结构:
// Routes registers the installation routes
func Routes() *web.Router {
base := web.NewRouter()
base.BeforeRouting(common.ProtocolMiddlewares()...)
base.Methods("GET, HEAD", "/assets/*", public.FileHandlerFunc())
r := web.NewRouter()
r.AfterRouting(common.MustInitSessioner(), installContexter())
r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL
r.Post("/", web.Bind[*forms.InstallForm](), SubmitInstall)
r.Get("/post-install", InstallDone)
r.Get("/-/web-theme/list", misc.WebThemeList)
r.Post("/-/web-theme/apply", misc.WebThemeApply)
r.Get("/api/healthz", healthcheck.Check)
r.NotFound(installNotFound)
base.Mount("", r)
return base
}
func installNotFound(w http.ResponseWriter, req *http.Request) {
w.Header().Add("Content-Type", "text/html; charset=utf-8")
w.Header().Add("Refresh", "1; url="+setting.AppSubURL+"/")
// do not use 30x status, because the "post-install" page needs to use 404/200 to detect if Gitea has been installed.
// the fetch API could follow 30x requests to the page with 200 status.
w.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprintf(w, `Not Found. <a href="%s">Go to default page</a>.`, html.EscapeString(setting.AppSubURL+"/"))
}
与 NormalRoutes() 的四处刻意差异:
- 不用
Contexter而用installContexter——安装页没有用户态、Flash 语义简化,也不该加载站点级模板数据(此时站点配置还不存在); /assets/*直挂FileHandlerFunc——注意它挂在base上且不带任何中间件:安装链没有 gzip(install 页资源少,无需压缩),但协议层五件套照常(panic 兜底不可省);installNotFound用 404 +Refresh头,注释给出不用 30x 的真实原因——post-install页面靠 404/200 区分"Gitea 是否已装好",若这里回 30x,fetch会跟着重定向到首页拿到 200,检测逻辑就把"还没装完"误判为"已安装"。同时Refresh: 1让浏览器一秒后自动跳到(很可能刚装好的)站点首页,兼顾人工访问的体验;- 同一进程两种 listen:安装态与已安装态互斥(
serveInstall/serveInstalled二选一),不存在同时挂载两套路由的歧义。GET /必须在根路径的原因写在注释里:install.js用window.location替换默认 AppURL 里的localhost——安装页的 URL 就是未来的站点地址。
安装路由存在的意义在 1.1 的架构全景里已经出现过——它是"数据库还没有"时唯一可用的 UI,所以它的中间件链是全站最短的(协议层 + session + installContexter,仅此而已)。
3.9 路由日志与可观测性
3.9.1 FuncInfo:每个 handler 的指纹
modules/web/routing/funcinfo.go 用反射在注册期取一次 file:line(funcName):
type FuncInfo struct { /* file, line, name, short */ }
func GetFuncInfo(handler any) *FuncInfo // 注册期一次,之后复用
func (fi *FuncInfo) String() string // "file.go:123(funcName)"
toHandlerProvider 与 wrapHandlerProvider 在每次执行时 defer routing.RecordFuncInfo(req.Context(), funcInfo)()——进入记录、退出计时。于是每条请求日志能精确到"哪个函数花了多久",而不是只有路由 pattern。启动期预检的 panic 消息也带它(3.3.4),错误定位是同一套坐标系。
3.9.2 RequestInfoHandler:慢请求与计数
modules/web/routing/requestinfo.go 的 NewRequestInfoHandler 在匹配前建请求记录,其中:
- 慢请求检测:超过 3 秒在请求完成日志中特别标记(配合
funcinfo找到耗时函数); - requestCounter:并发请求数统计,供 QoS/监控参考;
MarkLongPolling(context.go):/-/ws等路由自报长轮询——QoS 据此立即放行(3.5.6),日志也降噪;MarkLogLevelTrace:静态资源等高噪声路由把本请求日志降为 TRACE(3.7.1 的/assets/*就挂着它);UpdatePanicError:panic 信息挂到记录上,完成日志输出(3.4.3)。
3.9.3 请求完成日志的字段
routing/logger.go 输出一族消息:started/slow/polling/failed/completed,运行中的 fetch 任务降为 TRACE。isUnknownHandler(匹配不到 FuncInfo)升为 ERROR——出现"不知道是谁处理的请求"是路由装配 bug,用日志级别把它当错误暴露,这是把不变量交给监控的思路。
三件套合起来构成"可观测性横切面":访问日志(AccessLogger,面向运维格式)与路由日志(routing 包,面向开发定位)并行,共享同一 reqctx 记录——一次请求,两份视角。
3.10 源码级问答
Q1:为什么 handler 里 return 了,后面的中间件还会执行吗?
不会——如果 handler 写出了响应。反射型 handler 执行后 hasResponseBeenWritten(argsIn) 为 true 就不再 next.ServeHTTP(3.3.4)。区分两种形态:反射型 handler 即使自己 return 而没写响应,框架也会替它调 next(除非状态非 0);而标准中间件(func(http.Handler) http.Handler 形态)是否调 next 完全由它自己决定,语义等同 chi 原生中间件——return 不调 next 就是显式短路。WriteHeader 一旦执行状态即非 0,不存在"写了 header 还想继续"的模糊地带。
Q2:web.Bind 失败后 handler 还会执行吗?
会。Bind 只写 ErrorMsg 不写响应(3.3.5),短路判定为"未写出",继续执行 SignInPost 等 handler;handler 用 ctx.HasError() 决定重渲染。API 的 bind[T] 相反——ctx.APIError 写了响应,短路生效,handler 不执行。同一机制,两种业务语义。
Q3:请求 /user/login 时 Session 方法为何不覆盖已有登录态?
services/auth/session.go 的 Verify 读 session.KeyUID,若已是 individual 用户则返回该用户;而 verifyAuthWithOptionsWeb 的 SignOutRequired 分支在更外层(Group 之后的路由参数段)把已登录者重定向走(3.6.3)。顺序保证:先认证得到"你是谁",再由授权中间件决定"登录页不该见到你"。
Q4:为什么 BlockExpensive 必须在 auth 之后、QoS 之前?
它需要两个输入:ContextDataKeySignedUser(auth 写入)与 RoutePattern(chi 匹配期确定,AfterRouting 均可见)。放 auth 前会把登录用户误判为匿名;放 QoS 后则匿名重活已进队列,失去"免费拦截"的意义。17→18 的顺序是两个依赖的交集(3.5.6)。
Q5:Mount 之后子路由的 404 走哪个 NotFound?
chi 的 NotFound 是 mux 级的,router_test.go 的四个用例给出了完整规则(源码 342–349 行):
GET /no-such(root 未匹配)→ 只执行before-root,落进 root 自己的 NotFound——NormalRoutes()注册的它是http.NotFound(纯文本 404);GET /a/no-such(子前缀内未匹配)→before-root, root, before-sub, sub, not-found——子 mux 的 NotFound 先接管,且父的 AfterRouting 中间件照常执行。
对应到生产路由:web.Routes() 在 registerWebRoutes 末尾注册了 m.NotFound(context.GetWebContext + ctx.NotFound(nil),routers/web/web.go:1804,还带 RecordFuncInfo(WebNotFound) 埋点),所以 UI 路径下的未命中渲染的是完整 HTML 404 页面;而 /api/v1 等子 Router 没注册 NotFound(全库检索 api.go 无 .NotFound(),其前缀内的未命中落到 chi 默认的纯文本 404——API 返回 JSON/纯文本错误页而非 HTML,恰好符合客户端预期。
Q6:为什么 reqctx.ContextData 不加锁还安全?
单请求单 goroutine 执行模型:写入发生在中间件/handler 的同步调用链上(3.5.3)。风险只在两类异步场景:handler 自己起 goroutine 写 ctx.Data,或模板渲染期间并发读写——后者不存在(渲染前链已结束),前者是 bug 而非框架义务。源码注释 callers should ensure thread-safety 把责任划给了调用方。
Q7:CSRF 到底怎么防的?没有 token 吗?
没有 token。28.x 用 http.NewCrossOriginProtection()(Go 标准库),基于 Sec-Fetch-Site/Origin 拦跨站写请求(3.6.3)。代价是不支持不发这些头的老浏览器的跨站防护,收益是零 token 往返、无模板注入点。context.go 的 FIXME("we have dropped the csrf token")与仍保留的全局 ParseMultipartForm 是新旧交接的化石。
Q8:git clone 通过 HTTP 时走哪条认证路径?
/owner/repo/info/refs 与 /owner/repo/git-upload-pack 等路由注册时带 webAuth.AllowBasic + AllowOAuth2 + AllowDeployToken(3.6.2 实例)→ pre 段打标 → MiddlewareHandler 组出 OAuth2 → DeployToken → Basic → ReverseProxy → Session(sessionless,不建会话)→ optSignInFromAnyOrigin(关跨站保护,因为 git 客户端非浏览器)。密码可以是密码、PAT 或 token(Basic.VerifyAuthToken 内部按 OAuth2 token → PAT → action token 顺序试)。SSH key 不在此路径——它属于 sshd → gitea serv → /api/internal(3.6.4)。
Q9:为什么 pprof 不挂在主路由上?
servePprof()(cmd/web.go)注释三条理由:仅调试用途、不应暴露公网、"目前不值得引入配置项"。独立 localhost:6060 + 标准库 mux 意味着:主站任何路由/中间件 bug 都不影响 pprof,反之 pprof 的 pprof handler 也不经过 session/gzip/QoS 链(pprof 的 profile 输出是长响应,过 QoS 反而可能被丢)(3.2.5)。
Q10:注册路由时最后一个参数写 nil 会怎样?
wrapMiddlewareAndHandler 首检 isNilOrFuncNil(h[len(h)-1]) → panic "endpoint handler can't be nil"(3.2.3);中间的 nil 则被 isNilOrFuncNil 静默跳过——这支持条件中间件惯用法:BlockExpensive() 在关闭配置时返回 nil,仍可写在链上(3.5.6 的第一行 return nil)。
3.11 本章小结与章节衔接
本章从 cmd/web.go 的 routers.NormalRoutes() 出发,逐层拆开了 Gitea 的 HTTP 入口:
- 一层薄封装:
modules/web用 Router/Group 扩展 chi,换来注册期前缀累积、pre/normal 两阶段装配、反射 handler 与短路语义、启动期签名预检; - 一条双阶段链:BeforeRouting(可改写路径)与 AfterRouting(可见路由参数)之间是 chi 的匹配边界,3.5.1 的顺序表是全章的锚点;
- 三套认证:Web 动态组包(pre 打标)、API 静态组包(HTTPSign)、internal 进程鉴权(SSH key 在进程边界外),共享
Method接口与Group.Verify试错循环; - 一条跨站防线:
http.NewCrossOriginProtection取代了历史上的 CSRF token,逐路由可关; - 两态静态资源:assetfs 分层覆盖 + bindata 预压缩/Vite 反代,httpcache 统一条件请求政策。
与第 4 章(数据层)的衔接:本章所有中间件只把请求送到 ctx,ctx.Repo/ctx.Doer 背后的数据从哪来?RepoAssignment 的 repoAssignmentPrepare* 各步会调用 models/ 下的仓储层——第 4 章从 models/db 的会话管理、XORM/gorm 选型与迁移机制讲起,回答"一个 GetRepositoryByOwnerAndName 背后发生了什么"。阅读时可带着 3.6.5 的问题:权限判定所需的 access mode 是在哪一层算出来的?
与第 9 章(REST API)的衔接:本章只展示了 /api/v1 的外壳(Routes() 的 AfterRouting 四件套与 bind[T]),reqToken 的 scope 模型、sudo()、端点的参数校验与错误信封、Swagger 生成全部留给第 9 章。两章的接口就是 3.3 的反射机制:API handler 签名 func(ctx *context.APIContext) 与 Web handler 走同一套 toHandlerProvider,第 9 章的所有端点最终都注册进本章 3.8.3 的表格里。
一句话:第 3 章管"请求如何到达函数",第 4 章管"函数如何读写数据",第 9 章管"API 函数对外暴露什么契约"。