Gitea源码深度剖析-第3章 Web 路由与中间件体系

2 阅读1小时+

Gitea源码深度剖析-第3章 Web 路由与中间件体系

第 1 章用一节的篇幅描述了"一个 HTTP 请求进入 Gitea 之后会经过哪些阶段",那一节里的每一条横线,在源码里都对应一个真实的函数:它们有的挂在路由匹配之前,有的挂在匹配之后,有的是被反射包装过的业务 handler。本章把 1.5 节逐层放大,回答四个问题:

  1. Gitea 用什么框架接收 HTTP 请求,为什么在框架之上又包了一层 modules/web?
  2. 一个 handler 是怎样从 func(ctx *context.Context) 变成 http.HandlerFunc 的?写错签名为什么能在启动时就被发现?
  3. 从 TCP 字节流到业务函数之间,中间件的准确执行顺序是什么?每层各管什么?
  4. 静态资源、缓存头、错误页这些"横切面"分别由谁负责?

本章所有代码摘录均来自当前源码树(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/contextWeb/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
}

结构上只有四类动作:

  1. BeforeRouting:挂 common.ProtocolMiddlewares()——这一组是"路由匹配之前"的全局中间件,允许改写请求的 method 与 path(3.2 节展开);
  2. AfterRouting:挂 common.MaintenanceModeHandler()——所有子路由共用的"匹配之后"的第一道闸;
  3. Mount:五个(或按配置更多)子路由组;/ 挂 Web UI,/api/v1 挂 REST API,/api/internal 挂内部协议,包管理与 Actions 按 feature gate 条件挂载;
  4. 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 424 StatusFailedDependency)。源码注释明确警告不要把这个端点当 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
}

三个细节:

  1. Write 隐式补 200——符合 HTTP 语义(不调 WriteHeader 即默认 200),保证 WrittenStatus() 不会漏判"直接 Write"的响应。
  2. Hijack 保留并转发(WebSocket、/-/ws 长轮询需要),不支持时返回 ErrNotSupported 而不是 panic。
  3. 如果传入的 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
}

分三条路径:

  1. 标准中间件 func(http.Handler) http.Handler 与 http.HandlerFunc 形态 → 走 wrapHandlerProvider,只加一层 RecordFuncInfo 日志埋点,不进入反射路径(常规中间件零反射开销)。
  2. 反射型 handler → 每次请求:包 writer → prepareHandleArgsIn → fn.Call → 检查是否写出。
  3. 短路语义: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
			}
		}
	}
}

行为链:

  1. BindFormValidate[T] 用 validation.Binder() 把请求表单解码进 T 并做校验;
  2. SetForm(data, form) 把绑定结果放进请求数据仓库(键 __form),供 handler 用 web.GetForm[T](ctx) 取回;
  3. AssignForm(form, data) 把用户已填的值回写到模板数据——这是"校验失败后重新渲染表单还能看到刚才输入"的实现;
  4. 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.go302;外部重定向会删除 session cookie(防 Safari 跨站登出 bug);fetch-action 请求返回 JSON redirect
ctx.JSON(status, obj)base.go标准 JSON 输出
ctx.PlainText(status, str)base.gotext/plain; charset=utf-8 + X-Content-Type-Options: nosniff
ctx.NotFound()context_response.go按 Accept 决定 HTML 404 页还是纯文本
ctx.ServerError(status, msg)context_response.goerr == ErrNotExist 时降级为 404,否则 500
ctx.NotFoundOrServerError(err)context_response.go单一入口:NotExist → NotFound,其他 → ServerError
ctx.HTTPError(status, msg)base.go状态 + 带提示的错误消息
ctx.JSONRedirect/JSONOK/JSONErrorAuto/JSONError...context.gofetch-action 风格的 JSON 辅助,JSONErrorAuto 按错误类型自动选状态码与本地化消息
ctx.APIError*api.goAPI 侧同族,输出 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[&#34;handler 返回 error&#34;] --> N[&#34;ctx.NotFoundOrServerError(err)&#34;]
    E --> A[&#34;ctx.JSONErrorAuto(err)&#34;]
    E --> H[&#34;ctx.HTTPError(status, msg)&#34;]
    N -->|errors.Is ErrNotExist| F1[&#34;404 + Accept 协商&#34;]
    N -->|其他| F2[&#34;500 + status/500 模板&#34;]
    A -->|可翻译| G1[&#34;400 本地化消息&#34;]
    A -->|可展示| G2[&#34;error 自带状态码&#34;]
    A -->|内部错误| G3[&#34;500 通用文案 + 日志&#34;]
    H --> F3[&#34;指定状态码 + 提示消息&#34;]
    F2 --> P[&#34;renderServerErrorPage (Accept 协商, 禁缓存, outBuf 兜底)&#34;]
  • 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() 三处的注册按执行次序展开("?"表示受配置开关控制):

#阶段中间件定义位置职责
1BeforeRoutingChiRoutePathHandlerrouters/common/middleware.go让 chi 用 EscapedPath/RawPath 做路由匹配,%2f 不被提前解码
2BeforeRoutingRequestContextHandler同上建 reqctx 请求上下文、WrapResponseWriter、gtprof span、全局 panic 恢复、cache 上下文
3BeforeRoutingSecurityHeadersHandler同上X-Content-Type-Options、X-Frame-Options
4BeforeRoutingForwardedHeadersHandler?同上(chi-middleware/proxy)反代场景下按可信代理白名单改写 RemoteAddr/X-Forwarded-*
5BeforeRoutingrouting.NewRequestInfoHandlermodules/web/routing/requestinfo.go建请求记录(FuncInfo 栈、慢请求 3s 检测、requestCounter)
6BeforeRouting?context.AccessLoggerservices/context/access_log.go访问日志(setting.IsAccessLogEnabled() 时)
7BeforeRouting?public.ViteDevMiddlewaremodules/public/vitedev.go开发模式把前端请求反代到 Vite dev server
8BeforeRoutingchi_middleware.GetHeadrouters/web/web.goHEAD 未定义时转 GET(RFC 9110 §9.3.2)
9AfterRouting(根)MaintenanceModeHandlerrouters/common/maintenancemode.go维护模式拦截(白名单前缀放行)
10AfterRouting(webRoutes)gzhttp wrapper?routers/web/web.gogzip 压缩(MinSize 1400,级别 6,随机 jitter)
11AfterRoutingMustInitSessionerrouters/common/middleware.go会话(gitea.com/go-chi/session)
12AfterRoutingcontext.Contexterservices/context/context.go构造 *Base/*Context、Flash、locale 数据、Cache-Control、multipart 预解析
13AfterRoutingcommon.AuditOrigin(OriginUI)routers/common/audit.go标记请求来源为 UI,供审计使用
14AfterRoutingwebAuth.MiddlewareHandlerrouters/web/web.goWeb 认证组(OAuth2→DeployToken→Basic→ReverseProxy→Session→SSPI)
15AfterRoutinggoGetrouters/web/goget.go?go-get=1 返回带 <meta> 的 HTML(go import 协议)
16AfterRoutingcommon.PageGlobalDatarouters/common/pagetmpl.go懒加载未读通知数、秒表数据(sync.OnceValue)
17AfterRoutingcommon.BlockExpensive?routers/common/blockexpensive.go匿名用户的"昂贵路由"重定向到登录
18AfterRoutingcommon.QoSrouters/common/qos.gocodel 主动队列管理,高负载时丢弃低优先级请求
19AfterRoutingRouterMockPoint(BeforeWebRoutes)modules/web/routemock.go测试注入点
20路由/分组各 Group 中间件routers/web/web.goreqSignIn、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 顺序倒推其生命周期:

  1. reqctx.NewRequestContext 创建 requestDataStore(3.5.3),返回 finished——请求结束时执行 cleanUp()(关闭 AddCloser 注册的资源)并结束 process 记录;
  2. gtprof span 包住整个请求,span 属性在结束时补上最终匹配的 RoutePattern(此时才知道);
  3. recover → renderPanicErrorPage(3.4.3)——这是整个 Gitea 唯一的全局 panic 兜底,位于链的最外层之一,所以无论哪层中间件还是 handler panic,用户都能拿到 500 页;
  4. 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 valuesSetContextValue/GetContextValue是(RWMutex)请求内,finished 时清空pre 中间件打的 auth 许可标记(3.6.2)
cleanupsAddCleanUp/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
}

两点值得记录:

  1. TODO 注释是真实存在的设计缺陷记录:chi-session 用 gob 编码 Set 类型存 session,重启后第一次 Get 会因类型未注册而解码失败——所以每个往 session 里塞自定义结构的包必须在启动期 gob.Register(modules/session/key.go 定义的 KeyUID、KeyImpersonatorData 等就是这类键的规范清单)。阅读这段注释能理解 Gitea 启动序列里看似多余的注册调用。
  2. 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 引用处,这里列其动作序列):

  1. NewBaseContext(resp, req)——内部 panic 若 Base 已存在(防止双重 Contexter),注入 Locale(经 middleware.Locale)与 translation.ContextKey;
  2. NewWebContext——构造 Context(Flash、Link、空 Repo/Org、TemplateContext),并 SetContextValue(WebContextKey, ctx)(供 3.3.1 的注册 provider 取用);
  3. 合并 CommonTemplateContextData,设置 CurrentURL/Link/PageData;
  4. Flash cookie:读上一请求遗留的 flash 消息进 ctx.Data["Flash"];注册 ctx.Resp.Before(...) 回调,在响应写出时把本次新 flash 写回 cookie(或清除旧的)——Before 钩子是 WrapResponseWriter 提供的"响应前"动作;
  5. multipart 预解析(带 FIXME,见下);
  6. httpcache.SetCacheControlInHeader(...) 给 HTML 响应打默认缓存头;
  7. 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 的中间件,实际有三处压缩设施:

  1. gzhttp.NewWrapper(...)(10 位):HTTP 传输层 gzip,GzipMinSize=1400(小于阈值不压,避免小响应因压缩头反而变慢)、级别 6、RandomJitter(32, ...)——注释引用 klauspost 文档说明 jitter 是 BREACH 缓解措施(防攻击者通过压缩比推测响应内容);
  2. bindata 预压缩:构建期把静态资源同时生成 .gz,modules/public 直出时按 Accept-Encoding 选 gzip 版本,零运行时 CPU(3.7.2);
  3. 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
}

三条规则构成其语义:

  1. 失败不终止——注释解释了为什么:多个方法共用同一协议线索(Authorization: Bearer 既可能是 OAuth2 token 也可能是其他 token),OAuth2 报错时要给后面的尝试者机会;
  2. 首个成功即停,且记录 AuthedMethod(只记第一个)——AuthShared 用它判断是否 Basic 认证(影响是否展示"密码认证"相关 UI),API 层 checkDeprecatedAuthMethods 等也依赖;
  3. 全失败返回第一个错误——保留"最可能的原因"给用户(比如格式错误的 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 都写会话存储。

顺序的三处注释级理由:

  1. DeployToken before Basic——否则 Basic 会先把 deploy token 当密码去验,产生一次无谓的(且可能触发节流的)失败;
  2. ReverseProxy should before Session——反代注入的身份头若在 session 登录用户之后检查,永远轮不到它;
  3. 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❌
SSPIWindows 且启用,且必须最后❌ 注释明说 "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()
/explorerepos/users/organizations/code/topicsoptExploreSignIn(+ feature gate)匿名可浏览的发现页
/issues、/pulls、/milestones跨仓搜索与个人列表reqSignIn + gate
/user、/user/settings登录/注册/双因素/webauthn/openid/设置各页reqSignIn/reqSignOut、web.Bind登录相关走 SignOut/SignIn 变体
/login/oauthOAuth2 authorize/grantoptSignIn
/-/admin站点管理各页reqSignIn + AdminRequired(经 verify options)
/org组织页与设置reqSignIn、context.OrgAssignment两处 /org Group(955、962 行)分别服务浏览与设置
/repo仓库创建/模板/forkreqSignIn
/{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 httpaddOwnerRepoGitHTTPRouters 系AllowBasic/OAuth2/DeployToken + optSignInFromAnyOrigin + UserAssignmentWebsmart HTTP 协议端点
LFSLFS 相关同上(token 友好)
/api/swaggerswagger 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")
	...

三个手法值得注意:

  1. {type:issues} chi 正则约束——一个占位符同时匹配 /issues 与 /pulls 字面量,避免注册两条重复路由;
  2. Group 级中间件参数——RepoAssignment + RequireUnitReader 写在 Group(pattern, fn, middlewares...) 的可变参数里,组内所有路由共享,不用逐条重复;
  3. 注释即权限说明——// 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/v1routers/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/internalrouters/private/internal.goPrivateContexter + authInternal + setRealIP;供本地进程(gitea serv、hook、LFS 内部转发)
/api/packages、/v2routers/packages/(Packages.Enabled)常规包管理 API 与 OCI registry(/v2 必须根路径)
/api/actions、/api/actions_pipeline、ARTIFACT_V4routers/actions/(Actions.Enabled)runner 通信与 artifact 服务
/-/fetch-redirectcommon.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() 的四处刻意差异:

  1. 不用 Contexter 而用 installContexter——安装页没有用户态、Flash 语义简化,也不该加载站点级模板数据(此时站点配置还不存在);
  2. /assets/* 直挂 FileHandlerFunc——注意它挂在 base 上且不带任何中间件:安装链没有 gzip(install 页资源少,无需压缩),但协议层五件套照常(panic 兜底不可省);
  3. installNotFound 用 404 + Refresh 头,注释给出不用 30x 的真实原因——post-install 页面靠 404/200 区分"Gitea 是否已装好",若这里回 30x,fetch 会跟着重定向到首页拿到 200,检测逻辑就把"还没装完"误判为"已安装"。同时 Refresh: 1 让浏览器一秒后自动跳到(很可能刚装好的)站点首页,兼顾人工访问的体验;
  4. 同一进程两种 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 函数对外暴露什么契约"。