Gitea源码深度剖析-第9章 REST API v1 体系
第 1 章的总览导航表里,第 9 行写着这样一行:
| 9 | REST API | /api/v1 Mount 与 toHandlerProvider | routers/api/v1/api.go、services/、swagger |
第 3 章把路由框架讲完了:modules/web 如何包装 chi、Group/Combo/RouterPathGroup 如何拼中间件链、toHandlerProvider 如何用反射把 func(*context.Context) 和 func(*context.APIContext) 两种签名分发到同一条链上。当时留了三个钩子给本章------reqToken 背后的 scope 模型、sudo() 的身份置换、Swagger 生成机制------现在全部兑现。第 5 章讲权限时也留下一个反复出现的问题:"为什么 API 对没权限的仓库返回 404 而不是 403?"本章会在源码层面回答它。
需要先做一次"名词消毒"。任务清单里给的几个符号名,我在源码里逐一验证过,有四个并不存在,直接照着写会误导读者:
| 任务清单里的说法 | 验证结果 | 真实情况 |
|---|---|---|
checker reqSignedIn |
全仓库 routers/ 下 grep 无此函数 |
API 层的登录检查器叫 reqToken,web 层对应的是 reqSignIn |
错误响应函数 ErrorJSON |
全仓库 func .*ErrorJSON 无匹配 |
真实符号是 services/context/api.go 里的 APIError 结构体与 ctx.APIError(...) 方法族 |
convert 层函数 _visibility_of |
全仓库无匹配 | 可见性控制分散在 convert.toUser(signed, authed)、isOwnerHidden、publicOnlyTokenDeniedRepo、checkTokenPublicOnly 等真实函数里 |
模板 v1_json.tmpl |
templates/swagger/ 下无此文件 |
真实文件是 v1-input.json(占位输入)、v1-swagger.generated.json(生成产物)、v1-openapi3.generated.json(OAS3 转换产物),另有运行时替换占位符的 routers/web/swagger_json.go |
还有一处任务描述与事实不符的预期:它提到"doc/api_preview 版本预览机制"。本仓库没有 doc/ 目录,也没有任何 API 版本预览开关;API 的兼容策略体现在别处(X-Gitea-Warning 响应头、Deprecated 注解、modules/structs/api.go 里的字段注释),第 9.9 节按真实代码讲。
先给出本章的量化全景,后面每个数字都会在对应小节落到源码:
routers/api/v1/下带swagger:operation注解的操作共 537 个,带func(ctx *context.APIContext)签名的 handler 共 512 个------两者不相等,差值来自少数一函数多操作、以及纯辅助函数,9.11 节会拆这个差异。- 生成的 OpenAPI 文档:342 条 path、537 个 operation、225 个 schema definition、149 个 response、9 个 tag、7 个 securityDefinition。
- API 自身没有限流 :全仓库没有 API RateLimit 中间件,
429 Too Many Requests只出现在依赖扫描工具 pwn 的测试里。Gitea 唯一的请求级过载保护 QoS(codel 优先级队列)只挂在 web 路由上。第 9.10 节给出反证链。 - 认证走
buildAuthGroup()三种方案按序尝试:OAuth2(JWT/Actions token/PAT)→ HTTP Signature → Basic,EnableReverseProxyAuthAPI打开时再插入 ReverseProxy。
本章共 12 节,前 6 节讲机制(装配、上下文、认证、scope、checkers、bind 与错误信封),第 7 节把 4 个真实端点从 URL 走到数据库,第 8 节讲 convert 层,第 9 节讲版本化,第 10 节讲配额与限流的真相,第 11 节讲 OpenAPI 生成链路,第 12 节给出路由总表和一张端到端调用链图。
9.1 API 路由装配:Routes() 的四层结构
9.1.1 从 Mount 到 Routes()
API 的挂载点在 routers/init.go:184,与 web 路由同一行风格:
go
r.Mount("/api/v1", apiv1.Routes())
同文件附近还挂了 /api/internal(内部协议,非 REST)、/api/packages(registry 协议)、/v2(Docker registry v2)、/api/actions(Actions 专用协议)。它们都不是 v1 REST 的一部分,本章不展开------但这个事实本身重要:"Gitea 的 HTTP 面"由五套互不隶属的协议组成,REST v1 只是其一,所以它有独立的 context 类型、独立的错误信封、独立的文档生成。
Routes() 定义在 routers/api/v1/api.go:998-1931,将近 1000 行,是全仓库最大的单个函数。它的骨架是四层结构,从外到内:
go
func Routes() (r chi.Router) {
m := web.NewRouter()
m.Use(chi_middleware.GetHead) // BeforeRouting:HEAD 当 GET 处理
m.Use(cors.Handler(...)) // CORS 预检
m.BeforeRouting(...) // (本仓库在 API 上实际未注册 BeforeRouting 钩子,见下文)
m.AfterRouting(
context.APIContexter(), // ① 构造 APIContext
common.AuditOrigin(audit_model.OriginAPI), // ② 审计来源标记
checkDeprecatedAuthMethods, // ③ 弃用认证方式告警头
apiAuth(buildAuthGroup()), // ④ 认证组
verifyAuthWithOptionsAPI, // ⑤ OPTIONS 请求放行认证
)
...
}
(以上为骨架还原,省略了 CORS 细节与实际参数;逐行以 routers/api/v1/api.go:998-1060 为准。)
第一层是协议层中间件 。注意 chi_middleware.GetHead 通过 m.Use 注册而非 AfterRouting------它要在路由匹配阶段就生效。common.ProtocolMiddlewares()(routers/common/middleware.go)里那串 ChiRoutePathHandler → RequestContextHandler → SecurityHeaders → ForwardedHeaders → RequestInfo → AccessLogger → ViteDev 由外层统一注入,API 与 web 共享,第 3 章讲过。
第二层是四个 AfterRouting 钩子 ,这是 API 与 web 分岔的起点。第 3 章解释过 AfterRouting 的语义:这些中间件在 chi 完成路由匹配之后 执行,此时 chi.RouteContext 已经填好了 URL 参数(owner、repo、index......),但业务 handler 还没跑。API 把 context.APIContexter() 放在第一位,意味着后续所有钩子拿到的都是 *APIContext 而非 *context.Context。
第三层是资源组注册 ,Routes() 函数体的主体。全部资源组包在一个最外层组里:
go
m.Group("", func() {
m.Group("", func() {
// /users 组
m.Group("/users", func() {
...
}, tokenRequiresScopes(...scopes.User))
// /repos 组
m.Group("/repos", func() {
...
}, tokenRequiresScopes(...scopes.Repository))
...
}, sudo())
}, reqToken?) // 注意:最外层并不强制登录,公开端点匿名可访问
(骨架还原;真实代码里最外层是 m.Group("", func(){...}, sudo()),routers/api/v1/api.go 中 Routes() 的 m.Group("", func() 出现在认证组之后。)
sudo() 出现在最外层组,意味着所有 API 请求都要过一遍 sudo() 检查 ------但它只在管理员用 ?sudo=username 或 X-SUDO 头时才动作,普通请求直接穿透。9.3.4 节拆它。
第四层是资源内层组 ,以 /repos/{owner}/{repo}/issues 为例,中间件是叠洋葱:
scss
/ repos 组:tokenRequiresScopes(scopes.Repository)
└ repoAssignment() ← 解析 owner/repo,做可见性 404 判断
└ checkTokenPublicOnly() ← public-only token 过滤
└ issues 子组:tokenRequiresScopes(scopes.Issue)
└ mustEnableIssues() ← 仓库关了 issue 就 404
└ GET /issues → ListIssues
这解释了 API 中间件的双层 scope 结构 :/repos 组先要求 Repository scope(因为要"看见"仓库),issues 子组再要求 Issue scope(因为要"操作"issue)。一个只授了 repository 没授 issue 的 token,能 GET /repos/o/r 但不能 POST /repos/o/r/issues------9.4 节用 tokenRequiresScopes 的源码证明这一点。
9.1.2 资源组注册的统一模式
把 Routes() 里所有 m.Group 调用抽出来看,模式高度统一:
go
m.Group("/xxx", func() {
// 子组与端点
m.Group("", func() {
m.Get("", handler.List)
m.Post("", handler.Create)
m.Get("/{id}", handler.GetOne)
m.Patch("/{id}", handler.Edit)
m.Delete("/{id}", handler.Delete)
}, idAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(...scopes.Xxx))
三要素:scope 守卫 (组级)、资源 assignment (条目级,等价于 web 层的 repoAssignment 但作用于 APIContext)、public-only 过滤 (条目级)。Routes() 里 m.Group 的嵌套深度普遍是 3-4 层,对应"协议 → 资源类 → 资源实例 → 操作"。
一个值得注意的差异:web 路由的 assignment 常常返回 404 并渲染 HTML 页面,API 的 assignment 返回 404 并渲染 JSON 信封 。这是同一个 repoAssignment 逻辑在两个 context 类型上的分身------web 用 services/context/repo.go 的版本,API 用 routers/api/v1/api.go:147 的版本,两者都调 GetDoerRepoPermission,但错误出口不同。第 5 章分析过 web 版,本章 9.5 节分析 API 版。
9.1.3 CORS 与 OPTIONS
Routes() 里 cors.Handler 的配置来自 setting.API.CORS 相关设置。跨域对 API 的意义比 web 大------浏览器里的 SPA 要打 Gitea API,必须走 CORS 预检。而预检请求(OPTIONS)不携带 Authorization 头 (浏览器限制),所以如果认证中间件对 OPTIONS 也要求 token,预检必然失败。verifyAuthWithOptionsAPI(routers/api/v1/api.go:940 附近)就是为此存在的:它识别 OPTIONS 请求并跳过强制认证。同理 checkDeprecatedAuthMethods 对 OPTIONS 也不该发告警头------这些细节合起来说明一件事:API 的认证链对"非业务请求"做了显式豁免,而不是简单地一刀切。
9.1.4 与 web 路由的五点结构差异
| 维度 | web 路由(routers/web/web.go) |
API 路由(routers/api/v1/api.go) |
|---|---|---|
| context 类型 | context.Context(嵌 *Base) |
context.APIContext(嵌同一个 *Base) |
| 注册器 | context.Contexter() |
context.APIContexter() |
| 登录检查 | reqSignIn/reqSignInRequired |
reqToken |
| 错误出口 | ctx.NotFound/HTML 模板 |
ctx.APIError* → JSON 信封 |
| 输出渲染 | templates.RenderHTML 或 ctx.Redirect |
ctx.JSON(...) / ctx.JSONForHTML(...) |
| 限流 | 挂 QoS(web.go:309) |
无(见 9.10) |
共享的部分是 *Base:ctx.Req、ctx.Resp、ctx.Doer、ctx.Repo、ctx.Org、ctx.Data 全部同源。所以第 4、5、6、7、8 章讲的数据库层、权限层、Git 层、Issue/PR 服务层在 API 里原样复用------API 层只做三件事:参数绑定(bind)、权限守卫(checkers)、模型转换(convert)。这个"薄"是设计出来的,不是碰巧。
9.2 APIContext:与 web Context 的同与不同
9.2.1 结构定义
services/context/api.go 的核心结构:
go
type APIContext struct {
*Base
Doer *user_model.User // 0 when not signed in
IsSigned bool
IsBasicAuth bool
ContextUser *user_model.User // for user page
ContextOrg *org_model.Organization
Repo struct {
Repository *repo_model.Repository
Commit *git.Commit
GitRepo *git.Repository
Permission *perm_model.Permission
RefName string
IsWatching bool
Webhook *webhook.Webhook
Attachment *repo_model.Attachment
BranchName string
TreeName string
CommitID string
Paths map[string]string
PullRequest *issues_model.PullRequest
Issue *issues_model.Issue
Project *project_model.Project
MigratedBy *user_model.User
MigratedFrom string
WebsiteLink string
KanbanRepoColor string
CanPublic bool
}
Org struct {
Organization *org_model.Organization
Team *team_model.Team
OrgLink string
}
Package *packages_model.Package
PublicOnly bool
}
(字段节选自 services/context/api.go,省略了部分 Repo 子字段的注释。)
关键观察一:Doer *user_model.User 与 IsSigned bool 是显式字段 ,而 web 的 Context 里 ctx.Doer 是通过 Base 上的 ctx.Doer 同名字段暴露。两者同源于 AuthShared(routers/common/auth.go)------认证组跑完后,AuthShared 会设置 IsSigned/SignedUserID/IsAdmin/AuthedMethod,APIContexter 再把这些映射到 APIContext.Doer。认证与上下文构造是两个中间件的接力 :apiAuth(buildAuthGroup()) 在 AfterRouting 列表的第 4 位,context.APIContexter() 在第 1 位------先构造空 context,最后填身份。
关键观察二:IsBasicAuth bool 单独存在。它的用途在 convert 层(9.8 节):Basic 认证下 ctx.IsBasicAuth 为 true,某些字段(如完整 email)的可见性判断会用到。
关键观察三:PublicOnly bool 是APIContext 独有 的,web Context 没有。它由 checkTokenPublicOnly 在处理过程中置位,标记"当前请求受 public-only scope 限制"------9.4.3 节讲它的流转。
9.2.2 构造过程:APIContexter
services/context/api.go 的 APIContexter() 按顺序做六件事:
NewBaseContext------ 从http.Request/ResponseWriter构造*Base,初始化ctx.Data(map)、ctx.Data["Locale"]等。- 缓存上下文 ------
ctx.Cache,用于同一请求内传递可选值。 - Repo 上下文 ------ 初始化
ctx.Repo子结构(此时 Repository 还是 nil,等repoAssignment填)。 - Org 上下文 ------ 同上。
- SetContextValue ------ 往
chi的 context 里塞 Gitea 自己的键值。 - multipart 解析与缓存头 ------
ctx.Req.ParseMultipartForm(...)与httpcache.SetCacheControlInHeader(ctx.Resp, ...)。
第 6 步值得停一下:API 响应默认带 Cache-Control 。这让 HTTP 缓存层(httpcache,第 3 章提过的条件请求支持)能对 GET API 生效------配合 ETag/If-None-Match,重复的 GET /repos/o/r 可以走 304。这是 API 与 web 的一个隐性共性:两者都复用 httpcache,但 API 把缓存头做成显式中间件行为。
APIContext 还有一组方法值得列出,它们是 API handler 的"语法糖":
| 方法 | 作用 | 位置 |
|---|---|---|
TokenCanAccessRepo(user, repo) |
判断 token 能否访问该仓库(public-only 语义) | services/context/api.go |
ReferencesGitRepo(allowEmpty...) |
检查 ctx.Repo.GitRepo 是否已初始化 |
同上 |
RepoRefForAPI(fn) |
强制要求 GitRepo 存在,否则 panic | 同上 |
IsUserSiteAdmin/IsUserRepoAdmin/IsUserRepoWriter |
当前 Doer 的角色断言 | 同上 |
SetLinkHeader(page, total) |
写 RFC5988 Link 分页头 |
同上 |
JSONForHTML(status, data) |
JSON 输出但用 application/json;charset=utf-8 且对 HTML 转义 |
同上 |
RepoRefForAPI 的 panic 设计很直白------"no GitRepo, forgot to call the middleware?"。这暴露了 API 的一个隐性契约:需要 Git 数据的端点必须在路由上挂 repoAssignment() (它负责 git.OpenRepository)。忘记挂不会得到优雅的 500,而是在开发期直接 panic。9.7 节的 contents 端点会看到这个契约的实际约束。
9.2.3 谁在用 Base 的共享能力
*Base 是第 3、4、5 章的老朋友:ctx.Data(模板数据)、ctx.PageData、ctx.Locale、ctx.Render。API 里 ctx.Data 仍被 AuthShared 用来写 IsSigned/SignedUserID------因为 AuthShared 是 web/API 共用函数,它不知道自己服务哪种 context,只能往通用 map 里写。这是共享代码的代价:API 里有一部分"模板味"的中间状态永远不会被渲染 。Gitea 没有为此分裂 AuthShared,因为分裂意味着两份权限逻辑,风险更大。这类取舍在本仓库反复出现(第 4 章的 db 层、第 6 章的 git 层也是同一逻辑)。
9.2.4 一个源码级问答
Q:为什么 API 不复用 context.Context,要另造 APIContext?
三个原因,都能在上面的结构里看到:
- 字段精简 :
context.Context带Render、PageData、Session、Flash等 web 专属依赖;API 全用不上,带上反而诱导 handler 去渲染 HTML。 - 独有字段 :
IsBasicAuth、PublicOnly是 API 专属概念,塞进Context会让 web 侧出现"永远为 false"的噪声字段。 - 编译期隔离 :
func(*context.Context)和func(*context.APIContext)是不同类型,toHandlerProvider的反射分发(第 3 章)能在编译期阻止你把 web handler 注册到 API 路由上------反之亦然。如果两者同型,一个手滑就能让 API 端点返回 HTML。
代价是两个 context 各有一份 assignment/convert 代码。第 5 章引用过第 3 处权衡的结论:Gitea 选择编译期安全而不是代码复用。
9.3 认证机制:buildAuthGroup 的三段式
9.3.1 认证组的装配
routers/api/v1/api.go:911 的 buildAuthGroup():
go
func buildAuthGroup() *auth.Group {
group := auth.NewGroup(&auth.OAuth2{}, &auth.HTTPSign{}, &auth.Basic{})
if setting.Service.EnableReverseProxyAuthAPI {
group = auth.NewGroup(&auth.OAuth2{}, &auth.HTTPSign{}, &auth.Basic{}, &auth.ReverseProxy{})
}
return group
}
(节选自 routers/api/v1/api.go,注释略。)
顺序很重要 :OAuth2 排第一,意味着 PAT(Personal Access Token)优先于密码 。为什么需要这个顺序?因为 services/auth/basic.go 的 parseAuthBasic 里有一个历史兼容逻辑------当 Basic 认证的密码部分为空或等于 x-oauth-basic 时,用户名位置上的值被当作 token 处理:
go
// 伪代码还原 services/auth/basic.go 的 parseAuthBasic 分支
if password == "" || password == "x-oauth-basic" {
// username 实际是 token
}
(真实分支见 services/auth/basic.go;Gitea 从 1.13 起把 Authorization: Basic base64(token:x-oauth-basic) 作为 curl 的推荐写法之一。)
所以同一个 Authorization 头,可能是 token 也可能是密码------OAuth2 先试 token 语义(parseToken/userFromToken),失败了 Basic 再按密码语义走。"先 token 后密码"的组顺序,正是为了这个歧义。
Group.Verify(services/auth/group.go)的执行逻辑:按注册顺序逐个调用 Verify,第一个返回非 nil 且非"我管不了"的错误被保留;如果全部失败,返回最后一个错误。同时它把成功的方法记进 AuthedMethod,供 AuthShared 写到 ctx.Data["AuthedMethod"]------审计日志会用到。
注释里还有一句:API doesn't support SSPI auth。SSPI(Windows 集成认证)只在 web 层挂,API 不支持------因为 SSPI 是面向浏览器的协商式认证,脚本客户端用不了。
9.3.2 OAuth2 路径:PAT、Actions token、OAuth2 provider
services/auth/oauth2.go 是认证组的第一棒,它的判断树是全仓库最精巧的分支之一:
erlang
Authorization 头 / query 参数(?access_token= / ?token=)
├ query 参数形式:受 setting.DisableQueryAuthToken 控制
│ └ 关闭时读 query,否则忽略(并可发出弃用告警)
└ Authorization: Bearer xxx
└ token 值里含 "."?
├ 是 → 看前缀
│ ├ gnt/ → Actions JWT(services/auth/token_scope... 相关)
│ └ 其他 → OAuth2 provider JWT(gitea.dev/services/oauth2_provider)
└ 否 → 当作 PAT,走 GetAccessTokenBySHA 查库
(分支结构据 services/auth/oauth2.go 的 parseToken/userFromToken 还原。)
三个关键点:
1. query token 是被限制的。 setting.DisableQueryAuthToken 决定 ?access_token= 是否可用。为什么默认要限制?因为 query string 会进 access log、进 Referer、进浏览器历史------token 泄露面比 header 大得多。源码注释里明确提到该选项从 1.23 起的行为变化。任务清单说"OAuth2 token 校验在 services/oauth2_provider"------不完全对 :oauth2_provider 是签发方 (作为 OAuth2 authorization server 给第三方发 token),而 Gitea 自己的 PAT 校验在 services/auth/oauth2.go + models/auth/access_token.go。两者共存,见 9.3.3。
2. 带 "." 的 token 走 JWT 路径。 services/oauth2_provider/token.go 定义了 JWT claims 结构:
go
type Token struct {
GrantID int64 `json:"gnt"` // grant id
Kind string `json:"tt"` // token type
Counter int64 `json:"cnt"` // counter for one-time token
}
(节选自 services/oauth2_provider/token.go。)
kind 区分 access token / refresh token / one-time token;cnt 是一次性 token 的计数器------防止重放。校验时用 Gitea 的 JWT secret(setting.JWTSecret)验签,签发时间、过期时间都在标准 claims 里。
3. PAT 是查库的。 GetAccessTokenBySHA(models/auth/access_token.go)的实现是安全工程的范本,9.3.3 节完整拆。
userFromToken 成功后会做一件事:把 token 的 scope 写进 store.GetData()["ApiTokenScope"]。这个值随后被 basic.go 的 GetAccessScope 或 oauth2.go 的 GetOAuthAccessTokenScopeAndUserID 读出来,成为 9.4 节 scope 检查的数据源。scope 从"token 的属性"变成"请求上下文的属性",就是在这一步完成的。
9.3.3 Access token 表结构与常量时间校验
models/auth/access_token.go 的实体定义:
go
type AccessToken struct {
ID int64 `xorm:"pk autoincr"`
UID int64 `xorm:"INDEX"`
Name string `xorm:"index"`
Token string `xorm:"-"` //明文只在创建时存在
TokenHash string `xorm:"UNIQUE"` // sha256(token)
TokenSalt string
TokenLastEight string `xorm:"INDEX"` // 后 8 位,用于索引定位
Scope string `xorm:"TEXT"` // 逗号分隔 scope 串
CreatedUnix int64 `xorm:"INDEX created"`
UpdatedUnix int64 `xorm:"INDEX updated"`
}
(字段与 tag 节选自 models/auth/access_token.go,注释为本文所加。)
设计要点逐条看:
Token字段带xorm:"-":明文不落库。创建时setNewTokenValue生成 20 字节随机数 + 10 字节 salt ,hex 编码后,明文返回给用户一次,库里只存sha256(明文)、salt、以及明文的后 8 位。TokenLastEight建索引 :查库时先用 last-8 索引把候选缩到个位数(同一用户后 8 位碰撞概率极低),再对候选取 sha256 与TokenHash做常量时间比较 (CryptoConstTimeEqual)。这避免了"先比较 sha 字符串"可能引入的时序侧信道------虽然 sha256 本身不可逆,但比较耗时随匹配前缀增长这件事理论上可被利用。TokenHash是 UNIQUE:数据库层保证不重复。Scope是TEXT存逗号串 :这是持久化格式 ,与内存里的 bitmap 是两种表示,AccessTokenScope.parse()负责转换(9.4.1 节)。TokenCache:GetAccessTokenBySHA会经过一个进程内缓存,避免每个请求都打数据库。缓存失效靠 token 的UpdatedUnix/删除事件。
一个容易忽略的点:TokenLastEight 索引 + 常量时间比较的组合,说明 Gitea 的 token 校验是"索引定位 + 摘要比对",而不是"全表比对"。安全性来自 sha256 与常量时间比较,性能来自 last-8 索引。两者缺一不可------只有 sha256 意味着每个请求全表扫,只有 last-8 意味着后 8 位泄露即可定位候选。
9.3.4 sudo():管理员身份置换
routers/api/v1/api.go:111 附近的 sudo():
go
func sudo() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
sudoUser := ctx.FormString("sudo")
if sudoUser == "" {
sudoUser = ctx.Req.Header.Get("X-SUDO")
}
if sudoUser == "" {
return
}
if !ctx.IsSigned || !ctx.Doer.IsAdmin {
ctx.APIError(http.StatusForbidden, "you do not have permission to sudo")
return
}
u := user_model.GetPossibleUserByUserName(ctx, sudoUser) // 简化还原
if u == nil {
ctx.APIError(http.StatusNotFound, "user does not exist")
return
}
// 置换 ctx.Doer,并写审计
audit.Record(...)
ctx.Doer = u
}
}
(骨架还原,以 routers/api/v1/api.go:111 实际代码为准;省略了错误分支的完整文案。)
三个约束:
- 只对管理员生效 ------非 admin 用
?sudo=直接 403,而不是静默忽略。静默忽略会让调用方误以为操作以 sudo 身份执行了。 - 必须在认证之后 ------
sudo()挂在最外层资源组上,而认证是AfterRouting钩子;AfterRouting先于组中间件执行(第 3 章的链顺序),所以ctx.Doer已就位。 - 必须写审计 ------
audit.Record(...)记录"谁以谁的身份做了什么"。没有这条审计,sudo 就成了不可追溯的越权通道。
任务清单把 sudo() 列为第 3 章留的钩子之一,这里兑现:它的本质是在权限检查(checkers)之前替换 ctx.Doer ,因此后续所有 reqSiteAdmin/reqRepoWriter 判断都以被 sudo 的用户为准。如果把它放在 checkers 之后就毫无意义了------顺序即语义。
9.3.5 Basic 认证与 2FA 的交互
services/auth/basic.go 的 Verify 流程比"解码 header 查用户"复杂,因为要处理三类安全检查:
go
// 流程还原自 services/auth/basic.go 的 Verify
1. parseAuthBasic 解出 username/password(或识别为 token)
2. 先试 token 语义:VerifyAuthToken → 成功则返回
3. 退回密码语义:UserSignIn(ctx, username, password, ...)
4. 成功后检查:
a. 用户开启了 TOTP?→ 要求 X-Gitea-OTP 头,用 validateTOTP 校验
b. 用户 enroll 了 WebAuthn?→ 拒绝 Basic("WebAuthn enrolled")
c. 账户状态:NeedActivateAccount / LoginIsProhibited / NeedChangePassword
(流程据 services/auth/basic.go 与 routers/common/auth.go 的 CheckSignedInUser 还原。)
第 4b 步是个反直觉但正确的设计:enroll 了 WebAuthn 的账户禁用密码登录 。因为 WebAuthn 的安全前提就是"密码可能已泄露,所以才上硬件因子",若 Basic 还能用密码进去,WebAuthn 等于装饰。Gitea 的 web 登录页此时会走 WebAuthn 流程,而 API 的 Basic 路径直接拒绝------API 不提供 WebAuthn 认证,所以 API 上这类账户唯一的路是 token。
第 4a 步的 X-Gitea-OTP 头是 API 场景下 TOTP 的标准做法:curl 两步------先发带密码的请求收到 401 + 要求 OTP,再带 X-Gitea-OTP 重发。这也是 SecurityDefinitions 里 TOTPHeader 的来源(9.11 节会看到它在 swagger 头部注解里的定义)。
9.3.6 弃用认证方式的告警:checkDeprecatedAuthMethods
routers/api/v1/api.go:992 的 checkDeprecatedAuthMethods 不做拦截,只发告警头:
go
func checkDeprecatedAuthMethods(ctx *context.APIContext, next http.Handler) http.Handler {
// 识别 query token / 即将弃用的认证方式
// 若命中,写 X-Gitea-Warning 响应头,提示 gitea 1.23 将移除
...
}
(骨架还原,以 routers/api/v1/api.go:992 为准。)
它是 AfterRouting 的第 3 个钩子------排在认证之前 ,因为它要观察的是"请求用了什么认证方式",而这个信息在 ctx.Req 上,不需要 ctx.Doer。放认证后也行,但放前面能让告警在认证失败的 401 响应上也发出去(客户端才会看到警告)。这类顺序细节是 AfterRouting 列表排序的真实依据。
X-Gitea-Warning 是 Gitea 的非破坏性变更通道 :不改状态码、不改 body 结构,只加一个头。9.9 节讲版本化时会把它和 Deprecated 注解、x-oauth-basic 兼容一起归类。
9.3.7 401 与 403 的分工(API 层视角)
在源码里,这两者的分工非常机械:
| 状态码 | 触发函数 | 语义 | 源码位置 |
|---|---|---|---|
| 401 | reqToken:ctx.IsSigned 为 false |
"我不知道你是谁" | routers/api/v1/api.go:375 |
| 403 | tokenRequiresScopes:scope 不足 |
"我知道你是谁,但这个 token 权限不够" | 同文件 :328 |
| 403 | reqSiteAdmin/reqRepoWriter 等 checkers |
"你知道你是谁,但你的角色不够" | 同文件各 checker |
| 404 | repoAssignment:无任何单位访问权 |
"你不该知道这个仓库存在" | 同文件 :147 |
reqToken 的实现一句话:
go
func reqToken(ctx *context.APIContext) {
if !ctx.IsSigned {
ctx.APIError(http.StatusUnauthorized, "token is required")
return
}
}
(节选自 routers/api/v1/api.go:375 附近。)
注意 401 不带 WWW-Authenticate 头 ------严格按 RFC 7235,401 必须附带 challenge。Gitea 没有发,因为它的 401 更像"业务性拒绝"(你没给我 token),而非"请提供 Basic 认证凭据"。这个偏差在 API 客户端里通常无害(大家都看 body 的 {"message":...}),但如果你写严格的 HTTP 中间件需要区分,要知道 Gitea 的 401 是简化语义。
至于"为什么 API 返回 404 而非 403"------第 5 章 1637 行附近已经给了完整论证,这里只引用结论并补上 API 版的源码证据:repoAssignment 在两种权限缺失下返回 404:
go
// routers/api/v1/api.go 的 repoAssignment 尾部逻辑(骨架)
if !permission.HasAnyUnitAccessOrPublicAccess() {
// 你连读权限都没有 → 404(不该知道仓库存在)
ctx.APIError(http.StatusNotFound, "...")
return
}
if !ctx.TokenCanAccessRepo(ctx.Doer, repo) {
// public-only token 打私有仓库 → 404(同上,信息隐藏)
ctx.APIError(http.StatusNotFound, "...")
return
}
(骨架还原,以 routers/api/v1/api.go:147 起的 repoAssignment 实际代码为准。)
而当你能看见仓库但不能做某操作 时(比如只读 token 想 POST issue),才轮到 403。这条分界线是信息隐藏与操作拒绝的分界,不是"礼貌程度"的分界。9.5 节会给出一张完整的 checkers → 状态码表。
9.4 Scope 模型:位掩码、bitmap 与三层执行
9.4.1 存储与解析
models/auth/access_token_scope.go 定义了 9 个 scope category:
| category | 覆盖的资源组 |
|---|---|
activitypub |
ActivityPub 协议相关 |
admin |
站点管理(/admin/*) |
misc |
版本、签名、许可证、gitignore、label 模板等杂项 |
notification |
通知 |
organization |
组织、团队、成员 |
package |
包 |
issue |
issue、评论、标签、里程碑、项目 |
repository |
仓库、分支、标签、commit、raw、release、hook、协作 |
user |
用户、密钥、令牌、邮箱、gpg、star、watch、跟随 |
每个 category 有三个级别,用 AccessTokenScopeLevel 表示:
go
const (
NoAccess AccessTokenScopeLevel = iota // 0
Read // 1
Write // 2
)
(节选自 models/auth/access_token_scope.go。)
为什么用 bitmap? 源码注释直说了:only supports up to 64 token scopes------一个 uint64 每个 bit 代表一个"category × level"组合。AccessTokenScopeBitmap uint64 类型承载它。相比存字符串数组,bitmap 的优势是 HasScope/HasAnyScope 变成位与运算 ,O(1) 且无分配------这在每个请求要跑多次的 tokenRequiresScopes 里是实打实的热路径。
持久化格式仍是逗号串(AccessToken.Scope TEXT),parse() 负责转 bitmap。parse 手写逗号分割而不用 strings.Split,注释说明了原因:这是创建 token 与校验 token 的热路径 ,减少分配。字符串 "repository:write,issue:read" → bitmap,一次解析后缓存在 scope 对象里。
toScope() 做归一化:如果一个 token 授了某 category 的全部级别,序列化时折叠成 all(如 repository:write 单独存在时可归一为 repository?------以 toScope 实际逻辑为准,其效果是让存储串更短且语义等价)。
关键方法一览(均在 models/auth/access_token_scope.go):
| 方法 | 语义 |
|---|---|
HasScope(category, level) |
位与判断 |
HasAnyScope(...) |
任一满足 |
Normalize() |
归一化(折叠冗余表示) |
PublicOnly() |
是否带 public-only 限制 |
CanCreateChildScope(other) |
能否签发比自己更窄的子 scope(OAuth2 授 token 时用) |
EnforcePublicOnlyFrom(other) |
合并 public-only 限制 |
GetRequiredScopes(method, category) |
按 HTTP method 推导需要的 scope |
GetScopeLevelFromAccessMode(mode) |
AccessModeRead→Read、AccessModeWrite→Read(注意:写操作需要 Write,读需要 Read) |
9.4.2 tokenRequiresScopes:按 HTTP method 定级
routers/api/v1/api.go:328 附近的实现是 scope 执行的第一层:
go
func tokenRequiresScopes(scopes ...string) func(ctx *context.APIContext) {
requiredScopes := scopes
return func(ctx *context.APIContext) {
if ctx.Doer == nil { // 未登录:公开端点可继续,见下文
return
}
scopeStr, _ := ctx.GetData()["ApiTokenScope"] // 简化
...
// 按 HTTP method 决定 Read/Write 级别
required := accessTokenScope.GetRequiredScopes(ctx.Req.Method, category)
if !userScope.HasScope(category, required) {
ctx.APIError(http.StatusForbidden, "token scope insufficient") // 403
}
}
}
(骨架还原,以 routers/api/v1/api.go:328 实际代码为准。)
核心规则:GET/HEAD/OPTIONS 需要 read,其余方法(POST/PUT/PATCH/DELETE)需要 write 。GetRequiredScopes 把 method 映射到 AccessTokenScopeLevel,再和 token 的 scope 做 HasScope。失败 → 403(不是 404,也不是 401------你已认证,只是权限不够)。
这里有个微妙点:ctx.Doer == nil 时直接放行。为什么?因为匿名请求不该被 scope 拦截 ------公开端点(GET /repos/o/r、GET /version)匿名可访问,它们没有 token 就没有 scope 概念。scope 是附加限制 ,不是认证的替代。真正强制登录的是 reqToken。两者的组合语义:
- 匿名 + 公开端点 → 通过(
tokenRequiresScopes跳过,reqToken未挂或不触发) - token + 匿名可见资源 + scope 匹配 → 通过
- token + scope 不匹配 → 403
- 匿名 + 需登录端点 → 401 (
reqToken)
9.4.3 checkTokenPublicOnly 与 rejectPublicOnly
第二、三层是 public-only 执行,源码里是两个不同的 checker,作用在不同粒度:
checkTokenPublicOnly(routers/api/v1/api.go:253)------组级过滤 。它按 category 检查 token 是否带 public-only 标志,如果是,就设置 ctx.PublicOnly = true 并给查询加"只看公开资源"的约束。源码里它有一张 category → 是否受 public-only 影响的表,repository/issue 等 category 命中时生效。
rejectPublicOnly------端点级拒绝 。某些端点语义上无法"只看公开的"(比如 GET /user/repos 列自己的私有仓库),public-only token 打这些端点直接 403。与其在 handler 里到处加过滤,不如在入口一刀切。
两者配合 services/context/permission.go 的三个函数:
go
func isOwnerHidden(...) bool // fail closed:判断异常时视为隐藏
func publicOnlyTokenDeniedRepo(...) bool // public-only token 是否被该仓库拒之门外
func TokenIsPublicOnly(...) // 当前 token 是否 public-only
func CheckTokenScopes(...) // 综合 scope 检查
(函数名节选自 services/context/permission.go。)
isOwnerHidden 的 fail closed 值得强调:可见性判断的默认值是"不可见",只有明确证据才放行。这与第 5 章讲的权限模型一致------默认拒绝是 Gitea 权限代码的统一底色。
而 TokenCanAccessRepo(services/context/api.go)把这套逻辑收进 APIContext 的一个方法里,repoAssignment 和 GetByID 等端点调用它。它的存在解释了 9.3.7 表里最后一行 404 的来源。
9.4.4 scope 全链路时序
9.4.5 一个源码级问答
Q:GrantAdditionalScopes 是干什么的?为什么注释说 "since version 1.22, access tokens grant full access to the API"?
services/oauth2_provider/access_token.go 里,第三方应用通过 OAuth2 流程从 Gitea 拿 token 时,GrantAdditionalScopes(grant) 决定这个 token 携带哪些 scope。源码注释说明:1.22 之前 ,OAuth2 token 默认只有很窄的权限,需要 grant 显式附加 scope;1.22 起 ,如果没有附加任何 scope,GrantAdditionalScopes 返回 AccessTokenScopeAll------即全量访问。
这看起来反直觉(为什么默认放宽?),实际语义是:OAuth2 flow 本身已经过用户同意页,用户点"授权"就是同意全量 ;要求 grant 再声明 scope 反而让所有没声明的第三方应用静默变窄,破坏兼容。所以 Gitea 用"默认全量 + 显式收窄"替代"默认窄 + 显式放宽"。CanCreateChildScope 就是给"显式收窄"用的------grant 只能签发比自己更窄 的子 scope,不能放大。这条不变式(scope 只能收窄,不能放大)是整个 scope 模型的安全基石,无论 token 是 PAT 还是 OAuth2 签发的。
9.5 Checkers 全表与 401/403/404/423 语义
9.5.1 checkers 是什么
routers/api/v1/api.go 里散落着二十多个 func xxx(ctx *context.APIContext) 形式的小函数,它们被当作组中间件或端点中间件挂载,统一在请求进入 handler 前做前置检查 ,不通过就写一个错误响应并终止。源码里它们没有统一的 interface,约定俗成的形态就是"读 ctx → 判断 → ctx.APIError(...) + return"。
按职责可以把它们分成五族:
A. 认证与 scope 族 :reqToken、tokenRequiresScopes(...)、checkTokenPublicOnly、rejectPublicOnly------9.3、9.4 节已详述。
B. 站点角色族(拒绝 → 403):
| checker | 条件 |
|---|---|
reqSiteAdmin |
ctx.Doer.IsAdmin |
reqSelfOrAdmin |
是本人或是 admin |
reqExploreSignIn |
探索页要求登录 |
reqUsersExploreEnabled |
setting.Explore 用户页开关 |
C. 仓库角色族 (拒绝 → 403,需已过 repoAssignment):
| checker | 条件 |
|---|---|
reqRepoWriter |
permission.CanWriteany(unit)(任一单位可写) |
reqRepoReader |
至少一个单位有读权限 |
reqAnyRepoReader |
同上,变体 |
reqOwner |
仓库 owner 本人 |
reqRepoDangerZone |
owner 或站点 admin(危险区操作) |
D. 组织/团队族(拒绝 → 403):
| checker | 条件 |
|---|---|
reqOrgOwnership |
组织 owner |
reqOrgMembership |
组织成员 |
reqOrgVisible |
组织对当前用户可见 |
teamAccessPrivileged + denyNonTeamMember |
团队管理权限 |
reqTeamReadAccess / reqTeamMembership |
团队读/成员 |
E. 资源状态族 (拒绝常是 404 或 423,因为"资源不可用"不算权限问题):
| checker | 拒绝状态码 | 语义 |
|---|---|---|
mustEnableIssues |
404 | 仓库没开 issue 功能 |
mustAllowPulls |
404 | 没开 PR |
mustEnableIssuesOrPulls |
404 | 两者都没开 |
mustEnableWiki |
404 | 没开 wiki |
mustNotBeArchived |
423 repo is archived |
仓库已归档,只读 |
mustEnableEditor |
423 | 编辑器禁用 |
mustEnableAttachments |
404 | 附件功能关闭 |
reqGitHook |
404 | git hook 功能禁用(通常 admin-only) |
reqWebhooksEnabled / reqStarsEnabled / reqProjectsUnitAccess |
404 | 对应单位未启用 |
(各 checker 的定义位置均在 routers/api/v1/api.go,行号分散在 200-900 之间;条件为源码逻辑的忠实概括。)
9.5.2 423 的引入:归档仓库
mustNotBeArchived 返回的 423 Locked 是 API 里唯一 的非 4xx-常规语义。归档仓库的语义陷阱在于:它可见 (不是 404)、当前用户有权限 (不是 403),但任何写操作都必须拒绝 。403 会让客户端以为"再申请个更高权限的 token 就行",404 会误导"仓库没了",只有 423(以及它的 message repo is archived)准确表达"这是资源状态问题,不是权限问题,且不可通过换身份解决"。mustEnableEditor 同理------它对应 [repository.editor] DISABLED 类配置,属于"功能被管理员关了"。
9.5.3 完整状态码决策树
注意图里 checkTokenPublicOnly 有两条出边------能过滤就过滤(404/缩小结果集),不能过滤才拒绝(403) 。这个"优先过滤、退化才拒"的原则是 scope 与可见性代码的共同取向:GET /repos 对 public-only token 返回公开仓库子集(过滤),而不是 403(拒绝)。
9.5.4 assignment 家族:repoAssignment 的七步
repoAssignment()(routers/api/v1/api.go:147 起)是 API 里最重的单个 checker,完整步骤:
- 解析 owner :
ctx.Params("owner"]→user_model.GetUserByUsername,支持LookupUserRedirect(用户改名后的旧名重定向)。 - 解析 repo :
repo_model.GetRepositoryByOwnerAndName,支持LookupRedirect(仓库转移后的旧路径重定向)。 - 2FA 强制检查
doerNeedTwoFactorAuth:如果实例策略强制 2FA 而当前 Doer 没启用,直接把 Permission 降为PermissionNoAccess------效果等同于看不见仓库。这里有一个全仓库唯一的单测TestDoerNeedTwoFactorAuth(routers/api/v1/api_test.go),验证 nil、ActionsUser、DeployKeyUser 三种身份都不算"需要 2FA"------即自动化身份不受 2FA 策略限制。 - 算权限 :
GetDoerRepoPermission(ctx, repo, doer)------第 5 章详述过的权限计算入口。 - 可见性 404 :
!permission.HasAnyUnitAccessOrPublicAccess()→ 404。 - token 可达性 404 :
!ctx.TokenCanAccessRepo(doer, repo)→ 404(public-only 场景)。 - 打开 Git 仓库 :
git.OpenRepository→ctx.Repo.GitRepo,为ReferencesGitRepo/RepoRefForAPI的契约兑现。
第 3 步是"策略即权限"的直接体现:安全策略(强制 2FA)被折叠进权限计算 ,而不是另设一个 403 分支。好处是后续所有 checker 只需看 permission 一个事实源,坏处是------如果某个 checker 不看 permission 而直接查别的表,2FA 绕过就可能出现。Gitea 的防御是让 repoAssignment 尽早执行(组级),把降权后的 permission 塞进 ctx.Repo.Permission,下游统一消费。
9.5.5 orgAssignment 与用户 assignment
orgAssignment(requireMembership bool) 走类似的路:解析 org → 算 org 权限 → requireMembership 时要求成员身份。它挂在 /orgs/{org} 组上,参数区分"组织资源端点"(成员可访问)与"组织管理端点"(必须 owner)。
用户侧对应 UserAssignmentAPI(services/context/user.go)------注意它在 services/context 包里而不在 api.go ,因为 web 与 API 都要用它,只是错误出口不同(web 出 HTML,API 出 JSON)。GET /users/{username} 端点正是靠它拿 ctx.ContextUser。这是 9.2.4 问答的补充证据:能共享的逻辑,Gitea 仍然放在共享包里;只有 context 形态本身必须分裂。
9.5.6 源码级问答:为什么 checkers 不做成一个统一接口?
统一接口(比如 type Checker interface { Check(*APIContext) error })看起来更优雅,但 Gitea 没这么做,三个原因都能从代码读出来:
- 签名太短 :几乎所有 checker 就是
func(*context.APIContext),直接当 chi 中间件用最自然,包一层 interface 反而要多写 20 行适配。 - 组合方式不同 :
tokenRequiresScopes(scopes.User)是工厂 (闭包捕获参数),reqToken是裸函数 ,mustEnableIssues是端点级短路。统一接口要求它们都实现方法,实际只是把三种形态塞进一个形状里。 - 错误分支各异 :有的发 401、有的 403、有的 404、有的 423,还有的只设
ctx.PublicOnly不报错(checkTokenPublicOnly的"放行+标记"语义根本不是 Check 模型能表达的)。
第 3 点是决定性的:checkers 不是"验证器",而是"上下文加工者 + 守门人"的混合体 。硬套验证器抽象会把 checkTokenPublicOnly 这类"副作用型"中间件扭曲成返回 error 的函数,丢失"放行但标记"的表达力。
9.6 请求处理管线:bind、Swagger 注解与错误信封
9.6.1 bindT:泛型参数绑定
routers/api/v1/api.go:899 的 bind 是 API 请求体进入 Go 结构体的唯一入口:
go
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)
}
}
(节选自 routers/api/v1/api.go:899。)
四个细节:
- 泛型 + 模板参数
tmpl T:调用方写bind(api.CreateIssueOption{}),T 由实参推导。tmpl的值本身不使用,只借它的类型。 BindFormAny只应用bindingtag,不调Validate(modules/web/middleware/binding.go,第 3 章对比过BindFormValidate)。语义分离:binding:"required"是结构性错误(422),业务规则校验留给 handler(也是 422 但由 handler 精确控制 message)。- 绑定失败 → 422 Unprocessable Entity ,message 格式固定为
字段列表: 错误。422 而非 400,因为请求语法是对的(JSON 合法),只是语义字段不满足------RFC 7231 把这种情况划给 422。 web.SetForm(ctx, form):把绑定结果存进 ctx,handler 用web.GetForm[*api.CreateIssueOption](ctx)取回。类型参数必须与 bind 时完全一致 ,否则取到 nil------这是这套反射存储的类型安全边界(第 3 章讲SetForm/GetForm的 map+泛型实现)。
为什么不用 query 绑定同一个结构?因为 bind 只处理 body。query 参数走 ctx.FormString/utils.GetListOptions(9.7.3 节)------body 是"结构化资源",用泛型绑定;query 是"扁平选项",用逐字段读取。这是 REST 实现里常见的分治。
9.6.2 Swagger 注解体系
API 的文档不是手写的,而是从源码注释提取的。三类注解:
头部注解 swagger:meta ------routers/api/v1/api.go 文件顶部(package 声明前):
go
// swagger:meta
//
// ...
// SecurityDefinitions:
// BasicAuth:
// type: basic
// Token:
// type: apiKey
// name: Authorization
// in: header
// AccessToken:
// type: apiKey
// name: access_token
// in: query
// AuthorizationHeaderToken:
// type: apiKey
// name: Authorization
// in: header
// SudoParam:
// type: apiKey
// name: sudo
// in: query
// SudoHeader:
// type: apiKey
// name: X-SUDO
// in: header
// TOTPHeader:
// type: apiKey
// name: X-Gitea-OTP
// in: header
(节选自 routers/api/v1/api.go 顶部注释,7 个 securityDefinitions 与生成产物计数吻合。)
7 个安全定义与 9.3 节讲的认证机制一一对应 :BasicAuth(密码)、Token(Authorization: token xxx 头)、AccessToken(query token------文档保留了它但运行时可被 DisableQueryAuthToken 禁用)、AuthorizationHeaderToken(Bearer)、SudoParam/SudoHeader(9.3.4)、TOTPHeader(9.3.5)。文档即认证机制的清单。
操作注解 swagger:operation------每个 handler 文件顶部:
go
// swagger:operation GET /repos/{owner}/{repo} repository repoGet
//
// Get a repository
//
// ...
// ---
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// required: true
// type: string
// - name: repo
// in: path
// required: true
// type: string
// responses:
// "200":
// "$ref": "#/responses/repository"
// "404":
// "$ref": "#/responses/notFound"
(骨架还原,以 routers/api/v1/repo/repo.go 的 Get 注解实际内容为准。)
四个字段含义:HTTP method、path、tag (repository------9 个 tag 之一)、operationId (repoGet,客户端代码生成器用它命名方法)。全仓库 537 个这样的注解。
响应注解 swagger:response ------集中在 routers/api/v1/swagger/ 下的 15 个文件,每个定义一个带 swaggerResponse 前缀的结构体:
go
// swagger:response repository
type swaggerResponseRepository struct {
// in: body
Body api.Repository
}
(示意,实际文件见 routers/api/v1/swagger/repository.go 等。)
$ref: "#/responses/repository" 引用的就是它。149 个 response 定义 、225 个 definition (后者来自 modules/structs/api.go 里的 api.XXX 结构体------那里每个字段的注释也会被提取成 property 描述)。
9.6.3 错误信封:APIError 与 ctx.APIError 家族
任务清单里的 ErrorJSON 不存在,真实结构在 services/context/api.go:
go
// swagger:response error
type APIError struct {
// 详细错误信息
Message string `json:"message"`
// 链接到相关文档
URL string `json:"url"`
}
(节选自 services/context/api.go;url 字段指向 Gitea 文档站的相关页面,很多端点留空。)
ctx 上的方法族,全部最终调 ctx.JSON(status, APIError{...}):
| 方法 | 行为 |
|---|---|
ctx.APIError(status, msg) |
直接输出信封 |
ctx.APIErrorNotFound(msg) |
404,默认 message "not found" |
ctx.APIErrorInternal(err) |
500;只有非生产模式或调用者是 admin 才回传 err.Error() ,否则 "internal server error" |
ctx.APIErrorAuto(err) |
按 err 类型自动选状态码:util.ErrorIsErrNotExist → 404,ErrPermissionDenied → 403,其他 → 500 |
ctx.APIValidationError(errs) |
422,聚合 binding.Error |
ctx.APIInvalidTopicsError(errs) |
422,主题格式错误专用 |
ctx.APIForbiddenError() |
403 |
ctx.APIUnauthorizedError() |
401 |
ctx.APIConflict(msg) |
409 |
ctx.APIRepoArchivedError() |
423 |
(方法清单节选自 services/context/api.go。)
两个设计值得展开:
APIErrorInternal 的条件泄露 ------错误详情只给非生产环境或 admin。这防的是"500 响应把 SQL 语句/文件路径吐给匿名攻击者"。生产模式 + 普通用户 → 只有 internal server error + 服务端日志里的完整 stack。这是错误信息的权限分级,与 9.5 节的 404/403 分工同源:响应体的信息量本身就是一种权限。
APIErrorAuto 的错误类型路由 ------handler 里 files_service.GetContentsOrList 返回 util.NewNotExistErrorf(...) 时,ctx.APIErrorAuto(err) 自动出 404 而不需要 handler 写 if errors.Is(...) { ctx.APIError(404, ...) }。这把状态码决策从 handler 收敛到错误类型:service 层声明"这是不存在",API 层翻译成 404。9.7.4 节的 contents 端点会看到它的实际调用。
9.6.4 分页响应头
分页不只在 body 里,services/context/api.go 的 SetLinkHeader 写 RFC 5988:
ruby
Link: <https://gitea.example.com/api/v1/users/u/repos?page=2>; rel="next",
<https://gitea.example.com/api/v1/users/u/repos?page=5>; rel="last",
<https://gitea.example.com/api/v1/users/u/repos?page=1>; rel="first",
<https://gitea.example.com/api/v1/users/u/repos?page=1>; rel="prev"
(格式示例。)
genILinkHeader/genAPILinks 生成四个 rel。SetTotalCountHeader 写 X-Total-Count------注意不是所有列表端点都写总数 ,因为有些查询(尤其走 indexer 的搜索)count 代价高,端点按需调用。body 数组 + Link 头 + X-Total-Count 三件套是 Gitea 分页的完整契约,客户端翻页既可以数 body(NextPage 字段),也可以跟 Link 头(HTTP 标准做法)。
配套的取参函数是 routers/api/v1/utils/page.go 的 GetListOptions:从 page/limit query 读出 db.ListOptions,再经 convert.ToCorrectPageSize 归一------默认 30(setting.API.DefaultPagingNum),上限 50(MaxResponseItems) 。上限是硬性的:客户端传 limit=1000 会被削到 50,不报错。这与 9.10 节的"配额"主题呼应------Gitea 对 API 的保护走"削上限"而非"拒绝请求"。
9.6.5 管线全景
bind 排在 assignment 之后还是之前?从 Routes() 的真实注册顺序看,端点级的 bind 紧贴 handler 注册 (m.Post("/{index}/labels", bind(api.IssueLabelsOption{}), issues.ReplaceLabels)),即 bind 在所有组中间件之后。这个顺序是对的:先确认你有权对这个资源操作(403/404 优先),再解析你的请求体(422)------否则会把"权限错误"和"格式错误"的响应顺序颠倒,让探测者用 422/400 的差异枚举出资源存在性。
9.7 代表性端点端到端拆解
本节挑 4 个真实端点,从 URL 一路走到数据库与 Git,覆盖"读单个资源、写资源、列表分页、Git 内容"四种最典型的形态。
9.7.1 GET /repos/{owner}/{repo} ------ 读单个资源
路由注册 (routers/api/v1/api.go 的 /repos 组内):
scss
/repos 组:tokenRequiresScopes(...Repository)
└ repoAssignment(), checkTokenPublicOnly()
└ GET /{owner}/{repo} → repo.Get
swagger 注解 (routers/api/v1/repo/repo.go):
go
// swagger:operation GET /repos/{owner}/{repo} repository repoGet
//
// Get a repository
// ...
// responses:
// "200":
// "$ref": "#/responses/repository"
// "404":
// "$ref": "#/responses/notFound"
(骨架,实际注解还含 500 等响应,以源文件为准。)
handler 本体------短到只有几行,因为重活全在中间件和 convert 层:
go
// Get ...(swagger 注解略)
func Get(ctx *context.APIContext) {
// swagger:responses 200 见注解
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, ctx.Repo.Repository, ctx.Repo.Permission))
}
(节选自 routers/api/v1/repo/repo.go 的 Get。)
端到端链:
- chi 匹配
/repos/{owner}/{repo},抽出owner/repo参数。 AfterRouting五个钩子跑完 →ctx.Doer就位(或匿名)。sudo():无?sudo,穿透。tokenRequiresScopes(scopes.Repository):GET 方法要求repository:read;匿名则跳过。repoAssignment():owner → repo → 2FA 检查 →GetDoerRepoPermission→ 可见性 404 → token 可达性 404 → 打开GitRepo。checkTokenPublicOnly():public-only 标记。repo.Get执行:convert.ToRepo(ctx, repo, permission)组装api.Repository(9.8 节拆这个函数)。ctx.JSON(200, ...)输出;httpcache中间件决定是否带 ETag。
为什么 convert.ToRepo 要三个参数? ctx 用来拼绝对 URL(repo.HTMLURL(ctx)------AppSubURL 感知)、repo 是数据源、permission 决定 api.Permission 里的 admin/push/pull 三个 bool。权限信息是响应体的一部分:客户端拿到仓库就知道自己能不能 push,不用再发一次请求试错。这是 HATEOAS 式的设计取向------响应自描述。
404 的两副面孔 在这个端点上同时存在:仓库不存在/不可见 → repoAssignment 404;token 是 public-only 而仓库私有 → TokenCanAccessRepo 404。客户端无法区分"不存在"与"无权",这正是设计目标(第 5 章的论证)。
9.7.2 POST /repos/{owner}/{repo}/issues ------ 写资源
路由注册:
scss
/repos 组:tokenRequiresScopes(...Repository)
└ repoAssignment(), checkTokenPublicOnly()
└ issues 子组:tokenRequiresScopes(...Issue) + mustEnableIssues()
└ POST /issues → bind(api.CreateIssueOption{}), repo.CreateIssue
注意双层 scope 在这里生效:仓库要 repository,issue 要 issue,且 POST 方法要求两个 category 都是 write 级别。
handler (routers/api/v1/repo/issue.go 的 CreateIssue),swagger 注解声明的响应就有一排:201/403/404/412/422/423------六种状态码对应六类失败,是全仓库注解最丰富的端点之一:
go
func CreateIssue(ctx *context.APIContext) {
form := web.GetForm[*api.CreateIssueOption](ctx)
// 1. deadline 检查:设置截止日期需要写权限
// (只读协作者可以看 issue,但不该能改元数据)
if form.Deadline != nil && !ctx.Repo.Permission.CanWrite(eatIssues) {
ctx.APIError(http.StatusForbidden, "You do not have permission to set deadline.")
return
}
// 2. assignee 解析:名字 → ID,无法指派 → 422
ids, err := MakeIDsFromAPIAssigneesToAdd(ctx, form.Assignees, repo)
...
// CanBeAssigned 检查失败 → 422
// 3. 调 service 层创建
issue, err := issue_service.NewIssue(ctx, repo, opt, draft)
if err != nil {
if errors.Is(err, service_model.ErrBlockedUser) {
ctx.APIError(http.StatusForbidden, err.Error()) // 被 block 的用户 403
return
}
if errors.Is(err, issues_model.ErrPermissionDenied) {
ctx.APIError(http.StatusBadRequest, err.Error()) // 注意是 400
return
}
ctx.APIErrorInternal(err)
return
}
// 4. form.Closed:创建即关闭 → CloseIssue,依赖未完成 → 412
if form.Closed {
if err := issue_service.ChangeIssueStatus(...); err != nil {
if errors.Is(err, issues_model.ErrDependenciesLeft) {
ctx.APIError(http.StatusPreconditionFailed, ...) // 412
return
}
...
}
}
// 5. 重新查询完整数据并 201
issue, err = issues_model.GetIssueByIndex(ctx, repo.ID, issue.Index)
...
ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, issue))
}
(逐段节选自 routers/api/v1/repo/issue.go 的 CreateIssue,省略了部分错误分支与日志。)
这个端点值得精读的原因,是它暴露了 API 层的错误翻译不完全统一:
- deadline 权限不足 → 403(权限)
- assignee 不可指派 → 422(语义校验)
- service 层
ErrBlockedUser→ 403(权限) - service 层
ErrPermissionDenied→ 400(不是 403!) - 依赖未完成 → 412(前置条件)
- 归档仓库 → 423(由
mustNotBeArchived系列在更外层拦截)
ErrPermissionDenied → 400 而非 403 看起来像瑕疵------按本章的语义框架,权限不足应该 403。合理解释是:NewIssue 返回 ErrPermissionDenied 通常意味着请求内容本身越权 (比如指派了一个仓库外的人),把它当"请求格式/参数问题"处理也有依据。但无论解释如何,事实是 Gitea API 的错误码翻译按端点各自为政,没有全局 error → status 映射表 ;APIErrorAuto 只覆盖了少数错误类型(9.6.3)。写客户端时不能假设"权限错误一定是 403",要看每个端点的 swagger 注解声明。
201 的细节 :创建成功后重新 GetIssueByIndex 查询一遍 再 convert。为什么不在创建路径上直接用返回的 issue?因为 NewIssue 返回的结构可能缺关联数据(labels、assignees、attachments 都要按 issue ID 再查),重新查询保证 ToAPIIssue 拿到的是完整一致的快照------避免"创建响应里缺刚设置的 label"这类时序 bug。代价是多一次主键查询,对 201 响应可接受。
9.7.3 GET /users/{username}/repos ------ 列表与分页
路由注册 :/users 组挂 tokenRequiresScopes(...scopes.User),内部 userAssignment(拿 ctx.ContextUser),端点 ListUserRepos。
handler (routers/api/v1/user/repo.go):
go
func ListUserRepos(ctx *context.APIContext) {
opts := utils.GetListOptions(ctx) // page/limit → db.ListOptions
listUserRepos(ctx, ctx.ContextUser, opts)
}
func listUserRepos(ctx *context.APIContext, user *user_model.User, opts *db.ListOptions) {
repos, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{
ViewerID: ctx.Doer.ID, // 匿名时 Doer 为 nil,Go 里 ID 取零值
OwnerID: user.ID,
Page: opts.Page,
PageSize: opts.PageSize,
Private: true, // 先全查,后过滤
...
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
// 过滤:管理员或有任一单位访问权才保留
results := make([]*api.Repository, 0, len(repos))
for _, repo := range repos {
if ctx.IsSigned && ctx.Doer.IsAdmin ||
ctx.Repo.Permission.HasAnyUnitAccess(...) /* 简化 */ {
results = append(results, convert.ToRepo(ctx, repo, perm))
}
}
ctx.SetLinkHeader(page, db.CountParams...)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, results)
}
(逐段节选自 routers/api/v1/user/repo.go 的 listUserRepos/ListUserRepos,过滤条件按源码精神简化------真实代码按 permission.HasAnyUnitAccessOrPublicAccess 或 admin 判断。)
三个要点:
utils.GetListOptions(ctx):querypage/limit→db.ListOptions,convert.ToCorrectPageSize把 limit 夹到[1, 50],缺省 30。- 先查后滤 +
Private: true:查询把私有仓库也取出来,再按当前用户权限过滤。这意味着分页边界在过滤后才正确 ------SetLinkHeader/SetTotalCountHeader用的是过滤后的总数 ,不是 SQL 的Count(*)。这类端点必须小心 count 口径,否则客户端会看到X-Total-Count: 40但翻遍所有页只凑出 25 条。 convert.ToRepo循环:每行调一次,权限参数来自该仓库的独立 permission 计算。
同一文件还有两个孪生端点 :ListMyRepos(GET /user/repos,必须登录,rejectPublicOnly 通常挂在这里因为"自己的仓库"语义无法 public-only 化)和 ListOrgRepos(GET /orgs/{org}/repos)。三者共享 listUserRepos 内核,只是 owner 来源不同------一个内部函数 + 三个入口是列表类端点的典型组织方式。
9.7.4 Git 内容接口:contents / raw / branches / tags
这是第 1 章 1.3.2 节埋的伏笔兑现处------services/repository/files 反向依赖 routers/api/v1/utils.RefCommit。
Contents 端点 (routers/api/v1/repo/file.go):
bash
GET /repos/{owner}/{repo}/contents/{path}
GET /repos/{owner}/{repo}/contents (列根目录)
handler 分两支:路径为空 → GetContentsList,非空 → GetContents,最终都进:
go
func getRepoContents(ctx *context.APIContext, ...) {
refCommit, ok := resolveRefCommit(ctx, ref) // utils.ResolveRefCommit
if !ok { return } // 内部已发 404/422
ret, err := files_service.GetContentsOrList(ctx, repo, gitRepo, refCommit, opts)
if err != nil {
ctx.APIErrorAuto(err) // ← 9.6.3 讲的错误类型路由
return
}
ctx.JSON(http.StatusOK, ret)
}
(骨架还原,以 routers/api/v1/repo/file.go 实际代码为准。)
utils.ResolveRefCommit(routers/api/v1/utils/git.go)做 ref 解析:ref 参数先按 refs/ 前缀分支名猜 → 找不到再试 tag → 再试完整/短 objectID。三种引用类型一条函数链试完 ,全失败返回 util.NewNotExistErrorf,被 APIErrorAuto 翻译成 404。第 1 章说的反向依赖就在这:files.GetContentsOrList(ctx, repo, gitRepo, refCommit *utils.RefCommit, opts) 的第三个参数类型定义在 routers/api/v1/utils 包里(routers/api/v1/utils/git.go:17),services 层 import 了 routers 层 ------层次倒挂。它是怎么被接受的?因为 RefCommit 是个纯数据结构(RefName + Commit),不携带 HTTP 概念;真正引起注意的应该是依赖方向的例外被文档记录(第 1 章明确标注"第 9 章会再撞见"),而非被静默容忍。
GetContentsOrList 内部(services/repository/files/content.go)再分:entry 是 tree → SubTree.ListEntries 列目录(列表模式永远不含文件内容 ------源码 subOpts.IncludeSingleFileContent = false);否则取单文件。单文件内容走 GetBlobBySHA,这里有个配额检查:
go
if blobSize > setting.API.DefaultMaxBlobSize {
return ret, nil // 超过 10 MiB(默认),返回元数据但 Content 为空
}
(节选自 services/repository/files/content.go 的 GetBlobBySHA;DefaultMaxBlobSize = 10485760,见 modules/setting/api.go。)
这是"静默截断"而非报错 ------响应 200,但 content 字段缺失,客户端要靠 size > 10485760 自己判断该改用 GET .../raw/...。9.10 节会把这类"配额语义"归类。
Contents 注释里还有一句历史注记:Recommend users to use the contents-ext API instead------Gitea 扩展了一个非 GitHub 兼容的 contents-ext 端点 (对应 GetContentsOrListOptions 里 IncludeCommitMetadata/IncludeLfsMetadata 等 GitHub 没有的开关)。兼容性约束(字段结构必须像 GitHub)与功能扩展(想要 last commit、LFS 元数据)的矛盾,用新增端点 而不是改老端点解决------这是 API 演进的标准答案,9.9 节再展开。
Raw 端点 (同文件 GetRawFile):
go
func GetRawFile(ctx *context.APIContext) {
blob, ok := getBlobForEntry(ctx) // 解析 ref + path → git.Blob
if !ok { return }
// X-Gitea-Object-Type: raw/text/plain 等
common.ServeBlob(ctx, blob, ...) // 与 web 共用的下载逻辑
}
(骨架还原。)
common.ServeBlob 是 web 的 /raw/ 路径共用的------内容分发逻辑不分 web/API ,只是 X-Gitea-Object-Type 响应头由 API 侧加。ServeBlob 内部处理 Range 请求(断点续传)、Content-Disposition、LFS 指针展开。
Branches 端点 (routers/api/v1/repo/branch.go 的 ListBranches)有一个懒同步设计:
go
total, err := db.Count[...](...)
if total == 0 && !repo.IsEmpty {
// 数据库里一条分支都没有 → 首次访问,立即同步
if err := repo_service.SyncRepoBranches(ctx, repo.ID, -1); err != nil { ... }
// 重新 count
}
(骨架还原,以 routers/api/v1/repo/branch.go 实际代码为准。)
为什么需要它?分支信息在 Gitea 里有数据库缓存表 (branch 表),Git 里有真身。新仓库或刚迁移的仓库可能表空而 Git 有分支------如果 API 直接查表返回空数组,客户端会认为仓库没有分支。total == 0 && !repo.IsEmpty 这个条件精确刻画了"表空但仓库非空 = 缓存未建立",此时同步一次再查。缓存一致性策略从"事件驱动更新"退化出一条"读时修复"兜底。
Tags 端点 (routers/api/v1/repo/tag.go)走 git.GetTagInfos → convert.ToTag,没有类似的表缓存(tags 直接从 Git 读),所以也不需要懒同步------同一类资源(引用),有缓存的和没缓存的实现路径不同,行为契约保持一致(都返回完整列表)。
Git refs 端点 (routers/api/v1/repo/git_ref.go 的 getGitRefsInternal)的返回形态最特殊:
go
if len(refs) == 0 {
ctx.APIError(http.StatusNotFound, "no matching references found")
return
}
if len(refs) == 1 {
ctx.JSON(http.StatusOK, refs[0]) // 单条 → 对象
} else {
ctx.JSON(http.StatusOK, refs) // 多条 → 数组
}
(节选自 routers/api/v1/repo/git_ref.go。)
搜索到 1 条返回对象、多条返回数组 ------这是 GitHub API 的历史怪癖(GET /repos/o/r/git/refs/tags/v1 精确命中返回单对象),Gitea 为兼容而复刻。对严格类型的客户端(OpenAPI 生成的 Go/TS 客户端)这是灾难:同一 operationId 的响应类型是 oneOf [GitReference, []GitReference]。兼容优先于整洁是 Gitea REST 层的一贯取向,9.9 节的版本化讨论会回到这个主题。
9.7.5 四个端点的横向对比
| 维度 | repoGet | CreateIssue | ListUserRepos | GetContents |
|---|---|---|---|---|
| scope category | repository | repository + issue | user | repository |
| method 定级 | read | write | read | read |
| assignment | repoAssignment | repoAssignment | userAssignment | repoAssignment + ResolveRefCommit |
| bind | 无 | bind[CreateIssueOption] |
无(query) | 无(query/path) |
| 错误码 | 404 | 201/403/404/412/422/423 | 200/404 | 200/404/422 |
| convert 入口 | ToRepo | ToAPIIssue | ToRepo | files_service 原生 api.ContentsResponse |
| 特殊点 | 权限进响应体 | 六状态码 + 重查 | 过滤后计数 | 静默截断 + RefCommit 倒挂 |
最后一列揭示了一个例外:contents 类响应不走 services/convert ,而是 services/repository/files 直接产出 api.ContentsResponse。为什么?因为 contents 的字段(download_url/html_url/git_url/links)是 GitHub 兼容的资源自引用链接 ,本质是表示层 URL 拼接,没有"权限裁剪"需求(权限已在 assignment 挡掉)------套一层 convert 只会多一次结构体拷贝。convert 层的存在理由是字段裁剪与权限投影,不是格式转换本身;不需要裁剪的地方就不套,这是 9.8 节的引子。
9.8 convert 层:为什么存在,怎么裁剪
9.8.1 两个数据模型的鸿沟
Gitea 内部的 repo_model.Repository/user_model.User/issues_model.Issue 是数据库模型 :字段带 xorm tag、含 CreatedUnix int64 这类存储格式、含 Password/TokenHash/Email 这类绝不能直接吐出 的敏感字段、关联对象是懒加载的指针(Poster *user_model.User 可能为 nil)。
API 的 api.Repository/api.User/api.Issue(modules/structs/api.go)是契约模型 :字段是 JSON 友好的(Created *time.Time 指针表示可空)、URL 是绝对地址、关联对象必须是嵌套的完整对象而非 ID。
convert 层(services/convert/)就是这两个模型之间的单向阀门 :数据库模型 → API 模型,且只搬允许搬的。它解决四类问题:
- 敏感字段物理隔离 :
user_model.User有Password、Salt、LoginName、SourceID、ProhibitLogin------convert.toUser只在特定条件下才把其中几个搬过去(见 9.8.2)。数据库模型永远不直接进ctx.JSON。 - 权限投影 :同一仓库,admin 看和游客看的
api.Repository字段不同(permission.adminbool),convert 按传入的Permission计算。 - 懒加载兑现 :
ToAPIIssue里LoadPoster/LoadRepo/LoadLabels/LoadMilestone/LoadAssignees/LoadAttachments一串加载------数据库模型允许 nil 关联,JSON 不允许"半加载"。 - URL 绝对化 :
repo.HTMLURL(ctx)拼AppURL + owner/name,ctx参数就是为了读setting.AppURL与子路径。
9.8.2 ToUser 的两档开关:signed 与 authed
services/convert/user.go 的 toUser(signed, authed bool, ...) 是"字段裁剪"最清晰的样本。两个 bool 参数语义:
signed:当前请求有登录用户authed:当前登录用户就是被转换的本人,或是站点 admin
字段按这两个开关分三档:
| 字段 | 条件 | 理由 |
|---|---|---|
Email |
`signed && (!user.KeepEmailPrivate | |
IsAdmin |
authed |
站点管理员身份是敏感信息,只告诉本人和 admin |
LoginName / SourceID / LastLogin |
authed |
登录来源(LDAP 用户名等)泄露认证体系细节 |
Language / IsActive / ProhibitLogin |
authed |
账户状态是内部信息 |
IsRestricted 等管理字段 |
authed |
同上 |
Avatar / Username / FullName / Created |
无条件 | 公开身份字段 |
(条件依据 services/convert/user.go 的 toUser 实际分支整理。)
外层包装 toUser(signed, authed, ...) 再被 User(ctx, user, ...)(公开场景:authed=false)、UserProfile、UserList(user, signedUser, ...) 等导出函数复用------UserList(user, signedUser, ...) 批量转换时对每个 user 计算 authed (比较 signedUser.ID == user.ID || signedUser.IsAdmin),所以同一个列表响应里,自己的那条记录字段比别人的多。这是"响应内容依赖调用者身份"的直接体现,也是为什么 convert 必须接收 ctx 而不是只接收数据。
任务清单里的 _visibility_of 不存在 ------可见性不是单一函数,而是散落在每个 toXxx 的条件表达式里 。这个设计的取舍:集中式 visibility_of(field, viewer, target) 更好审计,但每个字段的可见性规则理由不同(隐私、安全、状态),集中后会变成一张充满例外的表。Gitea 选择规则贴近字段 ,代价是审计要通读 convert 文件。目前 services/convert/ 下 user.go/repository.go/issue.go/pull.go 等文件就是审计面。
9.8.3 ToRepo 与 innerToRepo
services/convert/repository.go 的 ToRepo(ctx, repo, permission, ...) → innerToRepo,主要装配:
Permission{Admin, Push, Pull}:由传入的perm_model.Permission折算成三个 bool------这是 API 客户端判断"我能干什么"的唯一依据。- units 配置 :
repo.UnitTypes→api.RepoUnit数组,告诉客户端哪些功能开着(issues/wiki/actions...)。 - 计数 :
db.Countreleases、CountBranches------列表端点里这是 N+1 风险点 :每个仓库两次 count 查询。listUserRepos页大小上限 50,意味着最坏 100 次 count------Gitea 接受这个代价(count 走索引),换取字段永远准确(不缓存陈旧计数)。 Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate------"内部仓库"(不可被非成员搜索发现但技术上是 public)的判定,owner 的可见性参与仓库的 API 表示,一个容易被客户端忽略的字段语义。- mirror / transfer / licenses 等元信息。
api.Repository 里还有一批 omitempty 字段(empty/archived/fork 默认 false 不序列化),响应体的字段集合随数据变化 ------客户端反序列化必须容忍字段缺省,不能假设 schema 固定。OpenAPI 文档里这些字段标注了 omitempty,但 JSON Schema 层面仍是 optional。
9.8.4 ToAPIIssue:懒加载链
services/convert/issue.go 的 ToAPIIssue(ctx, issue) → toIssue,内部一串 Load:
go
// 伪代码顺序,依据 services/convert/issue.go
LoadPoster(ctx, issue, ...) // issue.Poster *User
LoadRepo(ctx, issue, ...) // issue.Repo
LoadAttachments(ctx, issue, ...) // 附件列表
// LoadLabels / LoadMilestone / LoadAssignees / LoadProjects
// 若是 PR:LoadPullRequest → convert.ToAPIPullRequest
Assignee = Assignees[0] 的兼容保留是注释明说的:GitHub API 有 assignee 单数字段(历史遗留),Gitea 照搬以兼容,但内部是 assignees 数组------兼容字段从数组派生,数组为空时 assignee 为 null。
每次 Load 都可能触发一次 DB 查询,所以一个 issue 的完整转换可能是 7-8 次查询 。ListIssues 列 30 条 → 200+ 查询的量级是存在的。Gitea 的缓解手段是 Load 函数内部带请求级缓存(同一 ctx 内相同 user/repo 不重复查),但 label/milestone 是按 issue 分开查的。API 列表端点的性能特征由 convert 层决定 ------这是读代码时容易低估的一点:handler 本身只有 10 行,但一次 ctx.JSON 背后可能是上百次查询。
9.8.5 ToCorrectPageSize 与辅助转换
services/convert/utils.go 的 ToCorrectPageSize(pageSize int) int:
go
if pageSize <= 0 { return setting.API.DefaultPagingNum } // 30
if pageSize > setting.API.MaxResponseItems { return setting.API.MaxResponseItems } // 50
return pageSize
(骨架,依据 services/convert/utils.go 与 modules/setting/api.go。)
它被 utils.GetListOptions 调用------注意分页归一放在 convert 包而非 utils 包,因为 convert 包的职责被广义理解为"API 表示层的公共逻辑",不止模型转换。包名与实际职责的轻微错位是既有事实。
另外 services/convert/ 下还有 notification.go、org.go、team.go、label.go、milestone.go、release.go、package.go、webhook.go(output 转换)、activity.go 等------每个 api.XXX 结构体几乎都有对应的 ToXxx,225 个 definitions 对应的正是这套转换的输入输出两端。
9.8.6 源码级问答:convert 层可以绕过吗?
技术上可以------ctx.JSON 不认识也不检查你传的是什么,直接传 repo_model.Repository 也能序列化成功(Go 的 json 编码按导出字段走)。但你会立刻泄露 Password(User)、Token(Webhook 的 secret)等字段 ,因为数据库模型没有 json:"-" 全覆盖(有些敏感字段靠 xorm tag 而非 json tag 保护,两者不互通)。
所以规矩是纪律性的 :routers/api/v1/ 下的 handler 不 import 数据库模型直接进 ctx.JSON(ctx.Repo.Repository 这类传递给 convert 是允许的)。这个纪律没有编译期强制------它是 code review 要守的红线。Gitea 的 swagger 校验(9.11 节)提供了一层事后防线:如果响应结构与 swagger 注解声明的 $ref 不符,swagger validate 会报错------但只在 CI 里跑,且只覆盖有注解的端点。
9.9 版本化与兼容策略
9.9.1 v1 在路径里,且只有 v1
API 的版本前缀是 URL 的一部分:/api/v1。挂载点 routers/init.go:184 之后,没有任何 /api/v2 挂载 ------本仓库的路由注册只有 /api/v1、/api/internal、/api/packages、/v2(Docker registry 协议的 v2,与 REST 无关)、/api/actions。任务描述猜测存在 doc/api_preview 之类的版本预览机制,验证结果:不存在 。doc/ 目录本身在本仓库就不存在(只有 docs/,内容是 build-setup、development、guidelines-*、testing 等开发文档)。
所以 Gitea 的版本策略是单版本 + 内部兼容:
- 路径永不迁移 :
/api/v1是永久承诺,破坏性变更不会悄悄改 v1 的行为,而是走下面三条通道。 - 新增不破坏 :新字段带
omitempty加进api.XXX结构体;新端点直接加路由。老客户端对未知字段(Go 的 json.Unmarshal 忽略未知字段)和未知路径都无感。 - 废弃不删除:字段或端点标记 deprecated 后保留至少一个大版本,期间用告警信号提醒迁移。
这套策略的代价 是 modules/structs/api.go 单文件膨胀、Routes() 膨胀到近 1000 行------兼容性是用代码体积换来的。它的收益是 Gitea 的 API 客户端生态(gitea-swagger、各种语言的生成客户端)不需要做版本分支。
9.9.2 三条兼容信号通道
通道一:X-Gitea-Warning 响应头。 checkDeprecatedAuthMethods(9.3.6)在请求用了弃用认证方式时加头:
csharp
X-Gitea-Warning: query string authentication is deprecated and will be removed in gitea 1.23
(message 内容据 routers/api/v1/api.go 源码注释精神还原;具体版本号以源码为准------本仓库源码里提到 1.23/1.24。)
设计优点:不改变任何已有解析逻辑 (请求照样成功),只加一个响应头。客户端可以在 CI 里把 X-Gitea-Warning 当 lint------一旦出现就说明代码用了即将移除的特性。这是"软破坏性变更"的标准实现,比直接 410 Gone 温和,比静默兼容负责。
通道二:swagger 的 deprecated: true 注解。 swagger:operation 块里可以标 deprecated: true,生成到 OpenAPI 后,生成器(openapi-generator、swagger-codegen)会把对应方法标记为 deprecated,编译期给删除线警告。Go 侧代码里也有 // Deprecated: ... 形式的注释(Go 工具链标准),两者并存------源码警告给开发者,文档警告给客户端生成器。
通道三:响应字段级注释。 modules/structs/api.go 里字段的 doc comment 会进 OpenAPI property description,写明"此字段仅为兼容保留,请用 XXX 替代"。9.7.2 见过的 Assignee = Assignees[0] 就是活例子------assignee 字段在文档里描述为兼容字段,但不标 deprecated(因为 GitHub 自己也没废弃它,标了反而让"GitHub 兼容"承诺失效)。
9.9.3 兼容性承诺的边界:GitHub 对齐是第二约束
Gitea REST v1 的字段命名、返回形态大量复刻 GitHub v3 API------这不是风格偏好,而是兼容性契约 :无数工具(gh 的部分用法、hub、各种 CI 脚本、terraform provider)按 GitHub 的 schema 写,Gitea 想被当作 GitHub 的 drop-in 替代就必须逐字段对齐。9.7.4 的 git/refs 单条/数组切换、9.8.4 的 assignee 单数字段,都是明知别扭仍复刻的例子。
而当 Gitea 想要 GitHub 没有的功能时,策略是加平行端点而不是改老端点:
| 需求 | 冲突 | Gitea 的解法 |
|---|---|---|
| contents 返回 last commit、LFS 元数据 | 改 contents 字段会破坏 GitHub 兼容 |
新增 contents-ext 端点,源码注释明示 "Recommend users to use the contents-ext API instead" |
| 错误响应带结构化细节 | 改 {"message","url"} 信封破坏所有客户端解析 |
不改;细节走 X-Gitea-Warning 头或新增字段(同信封加字段是兼容的) |
| 分页要总数 | GitHub 不返回 total | 加非标准头 X-Total-Count------加头不改 body,老客户端无视即可 |
三条解法的共同点:破坏性变更的替代品永远是"新增"(新端点、新头、新字段),绝不改既有语义 。Link 头是标准的(RFC 5988),X-Total-Count 是事实标准(很多 API 用),两者都在 body 之外,属于"HTTP 层的扩展空间"------Gitea 把能放头部的信息都放头部,body 保持 GitHub 兼容。
9.9.4 破坏性变更真的发生了怎么办
虽然本仓库没有 v2 机制,但 Gitea 官方的既定立场(体现于历史版本行为与源码注释的版本引用)是:破坏性变更只在 Gitea 大版本升级时发生,且会先经过一个版本的 deprecation 告警期 。源码里的证据是那些写死的版本号字符串------checkDeprecatedAuthMethods 提示 1.23 移除、DisableQueryAuthToken 相关注释提 1.23/1.24------告警先行、版本号写死在 message 里、到期移除,这是三段式流程。本仓库正处在某个"告警已发出、尚未移除"的时间点上,所以代码里能看到弃用逻辑与被弃用逻辑并存。
对使用方的实操建议(从源码反推):
- 监控
X-Gitea-Warning头,出现即视为 lint 失败。 - 读 swagger 文档时过滤
deprecated: true。 - 不要依赖 400/403/404 的精确选择(9.7.2 已证明按端点各异),只依赖 swagger 注解里声明的状态码集合。
- 反序列化时忽略未知字段,序列化时只依赖 swagger 声明的必填字段。
9.9.5 一个源码级问答:为什么不学 CalVer/日期版本头(如 Accept: application/vnd.github.v3+json)?
GitHub 用 Accept 头做媒体类型版本,因为它的 API 在同一路径下演进了多个不兼容代。Gitea 不需要,两个原因:
- 单实现演进:Gitea 的 v1 只有一代,从来没有"同路径两个行为"的时期------没有需求就不引入机制。这是 YAGNI 在 API 设计上的体现。
- 媒体类型版本需要每个端点声明支持哪些版本 ,OpenAPI 里要写
x-扩展或重复 operation,537 个 operation 的维护成本乘以版本数------本仓库的 swagger 注解已经接近手写极限(每 operation 一个 YAML 块),再加版本矩阵不可维护。
反过来的问题是:如果哪天真有破坏性变更怎么办?答案回到 9.9.1------那时才需要 /api/v2,而 /api/v2 挂载点只需在 routers/init.go 加一行 r.Mount("/api/v2", apiv2.Routes()),两代并存运行。架构上早就留好了位置(Mount 模式天然支持多版本共存),只是还没用。
9.10 限流与配额:一个重要的反证
9.10.1 Gitea API 没有限流
任务清单第 7 点问"API 是否有限流(modules 中找)"。我把搜索做全了,结论是一个否定证明:
- 全仓库 grep
StatusTooManyRequests(429) :唯一命中在 pwn 的依赖测试 fixture 里,业务代码零命中。没有任何 handler 或中间件返回 429。 - grep rate limit / RateLimit 相关中间件 :
modules/下没有 API 限流中间件。唯一带 "RateLimit" 字样的业务代码是services/migrations/github.go------那是消费 GitHub API 时处理对方的限流 (读到X-RateLimit-Remaining为 0 时退避),与 Gitea 自己的 API 无关。它的错误最终在routers/repo/migrate.go被转成 422Remote visit addressed rate limitation.------注意这是迁移功能的业务错误,不是 Gitea 对客户端限流。 routers/common/qos.go的 QoS ------Gitea 唯一的请求级过载保护,codel 优先级队列(登录用户 HighPriority、匿名 Default、/{username}/{reponame}/前缀的 web 页面 LowPriority)。但它只挂在 web 路由 (routers/web/web.go:309附近),routers/api/v1/api.go的Routes()里没有QoS()调用。common.ProtocolMiddlewares()不含限流------第 3 章列过的七个协议中间件(ChiRoutePathHandler/RequestContextHandler/SecurityHeaders/ForwardedHeaders/RequestInfo/AccessLogger/ViteDev)没有任何令牌桶、漏桶、滑动窗口实现。
所以事实是:Gitea REST API 默认对请求速率不设任何限制。同一个 IP 可以用合法 token 无限速打满 537 个端点。
9.10.2 为什么这是有意为之(从代码反推的设计动机)
QoS 的注释给出了设计哲学的直接线索:
go
// QoS implements quality of service for requests, based upon whether
// or not the user is logged in. All traffic may get dropped, and
// anonymous users are deprioritized.
(节选自 routers/common/qos.go。)
注意 QoS 解决的是另一个问题 :过载时的公平性与降级(谁被 drop、谁先通过),而不是滥用防护(谁请求太频繁)。它的触发条件是"在途请求超过 MaxOutstanding",是容量信号 而非频率信号。即便把 QoS 挂到 API 上,语义也是"服务器忙时丢弃低优先级请求(503)",不是"你请求太多(429)"。
而频率限流 需要身份归类(按 IP?按 token?按用户?)、窗口状态(内存/Redis)、配置项([api] RATE_LIMIT 之类)------这些在 modules/setting/api.go 里全都不存在。该文件只有 EnableSwagger/SwaggerURL/MaxResponseItems/DefaultPagingNum/DefaultGitTreesPerPage/DefaultMaxBlobSize/DefaultMaxResponseSize 七个键。配置面就是能力面------没有配置项的功能,功能不存在。
设计动机可以从三个事实推断:
- Gitea 是自托管软件 :部署者可以在反向代理层(nginx
limit_req、Cloudflare)加限流,Gitea 内置反而冲突(代理已限流,应用层再限会双重计算)。自托管软件的惯例是把网络层策略留给网络层。 - API 的重端点有自己的配额(见 9.10.3),"限流"被具体化成"每个请求的代价上限"而非"每时间窗的请求数"。
- CI/CD 是合法的大流量场景:Git clone 后的 API 调用、Actions 的批量操作天然突发------内置限流最容易误伤的目标就是最忠实的用户。QoS 只保护 web 浏览体验(登录用户优先),API 的批量流量被刻意放行。
9.10.3 真实存在的配额:按请求代价设上限
虽然没有时间窗限流,但每个请求的资源消耗都有硬上限,这才是 Gitea 的"配额体系":
| 配置项 | 默认值 | 作用点 | 超限行为 |
|---|---|---|---|
setting.API.MaxResponseItems |
50 | convert.ToCorrectPageSize |
静默削到 50(9.6.4) |
setting.API.DefaultPagingNum |
30 | 同上,page/limit 缺省 | 默认页大小 |
setting.API.DefaultMaxBlobSize |
10485760(10 MiB) | files.GetBlobBySHA |
200 但 content 字段缺失(9.7.4) |
setting.API.DefaultMaxResponseSize |
104857600(100 MiB) | 大响应体读取 | 截断 |
setting.API.DefaultGitTreesPerPage |
1000 | git trees 端点 | 每页条目上限 |
setting.Attachment.MaxSize |
100(MiB) | attachment.uploadAttachment |
422 attachment exceeds limit |
setting.Attachment.MaxFiles |
5 | 多文件上传 | 超出拒绝 |
setting.Repository.Release.FileMaxSize |
(配置) | UploadAttachmentForRelease |
同附件 |
(默认值依据 modules/setting/api.go 与 modules/setting/attachment.go。)
附件上传的实现值得看(services/attachment/attachment.go):
go
func uploadAttachment(ctx context.Context, file *UploaderFile, allowedTypes string, maxFileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
src := file.rd
if file.size < 0 {
src = http.MaxBytesReader(file.respWriter, src, maxFileSize) // 未知大小:流式硬截
}
buf := make([]byte, 1024)
n, _ := util.ReadAtMost(src, buf)
buf = buf[:n]
if err := upload.Verify(buf, attach.Name, allowedTypes); err != nil {
return nil, err // 前 1KB 魔数 + 扩展名白名单
}
if maxFileSize >= 0 && file.size > maxFileSize {
return nil, util.ErrorWrap(util.ErrContentTooLarge, ...) // 已知大小:先查再收
}
attach, err := NewAttachment(ctx, attach, io.MultiReader(bytes.NewReader(buf), src), file.size)
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
return nil, util.ErrorWrap(util.ErrContentTooLarge, ...) // 流式超限在读错里
}
return attach, err
}
(节选自 services/attachment/attachment.go。)
三条防御线清晰分层:
- 已知 Content-Length → 直接比较
file.size > maxFileSize,连读都不读就拒绝。 - 未知大小 →
http.MaxBytesReader包住流,读到超限时io.Reader返回*http.MaxBytesError,Go 标准库会顺带把连接标记为不可复用。这里有个细节:MaxBytesReader还会在超限时自动给 ResponseWriter 写 413 ------所以 Gitea 后续把MaxBytesError转成自己的ErrContentTooLarge时,实际响应可能已经是标准库写好的 413 状态。 - 内容白名单 →
upload.Verify只读前 1024 字节做魔数嗅探(util.ReadAtMost),扩展名白名单(setting.Attachment.AllowedTypes默认含.avif .csv .docx ... .zip三十多种)------防的是改扩展名的可执行文件上传,不是防病毒。
NewAttachment 里 storage.Attachments.Save(...) 把内容写对象存储,db.Insert 建记录------两步在一个 db.WithTx 事务里,避免"存储成功但 DB 失败"产生孤儿文件(DB 是主,存储失败回滚)。
"静默削上限"与"显式报错"的分工规律 :读操作(分页、blob 内容)超限 → 降级返回(削页大小、去掉 content),因为客户端还能拿到部分有用结果 ;写操作(上传)超限 → 显式报错,因为客户端必须知道写入没发生 。这是配额语义的一致原则------读降级、写拒绝。
9.10.4 搜索与特殊端点的代价控制
搜索端点(GET /repos/search、issue 搜索)走 indexer(bleve/db 索引),代价天然高于普通 SQL。控制手段:
- 分页上限 50------索引查询的窗口被硬性限制。
api.SearchError包装 (routers/api/v1/repo/repo.go的Search)------搜索失败不是裸 500,而是转成api.SearchError{OK bool, Data, Errors []string}结构。这给了搜索一个部分成功 的表达空间(indexer 超时可返回OK=false+ 空 Data 而非整个请求失败)。utils.ResolveSortOrder------排序字段是白名单解析,非法值 → 422 而不是拼进 SQL。这防的是注入面,也是"输入即配额"的一部分(不合法的排序可能触发全表扫)。
/repos/search 的另一个特点是匿名可访问但受 explore 配置控制 (reqUsersExploreEnabled 类似物)------管理员可以关掉公开搜索,此时未登录请求 404/403。可见性开关是比限流更根本的流量开关:关了就一个请求都不会到数据库。
9.10.5 如果你需要限流,源码给的位置
对部署者,从代码结构看三个介入点:
- 反向代理层 (推荐):Gitea 前置 nginx/caddy,对
/api/v1/前缀做limit_req。Gitea 不会因此行为异常------API 全部走标准 HTTP,无长连接协议(除 SSE 类端点)。 ProtocolMiddlewares注入 :在routers/common/middleware.go的协议链里加一个自定义限流中间件。它会同时作用于 web 和 API,需要按routing.GetRequestRecordInfo(ctx)或chi.RouteContext的RoutePattern区分流量(requestPriority就是这么区分的------strings.HasPrefix(routePattern, "/{username}/{reponame}/")判 web 页面)。- AfterRouting 注入(仅 API) :改
routers/api/v1/api.go的m.AfterRouting(...)列表,加在apiAuth之后(能按ctx.Doer.ID/token 归类)。这是"每个 token 独立配额"的正确插入点,因为此时身份已知。
这个"留缝"设计与 9.9.5 是同一哲学:核心不内置策略,但结构上保证策略可插入。
9.10.6 源码级问答:429 语义的缺失会造成误用吗?
会,而且有真实影响:重试策略 。标准 HTTP 客户端(如 Go 的 golang.org/x/time/rate 配合、或 SDK 的 retry 中间件)通常把 429 当作"退避后重试"信号,5xx 当作"可能重试"信号。Gitea 两者都不明确给:
- 无 429 → 客户端无法区分"我太快了"与"我打错了"。
- 限流场景实际会表现为 503 (如果前面有代理限流)或超时------客户端只能按 5xx/超时退避。
务实结论:对 Gitea API 写客户端时,把"无响应/503/超时"统一实现为带 jitter 的指数退避 ,不要期待 429。而服务端开发者 要清楚:你的 API 用户没有限流保护,一个失控的脚本可以打垮实例------QoS 不会救 API(它压根不在链上),唯一内置的保护是各端点的代价上限。这是 Gitea 选择"部署者负责流量治理"的直接后果,读源码时必须知道的运营事实。
9.11 OpenAPI 生成链路
9.11.1 从注解到 JSON 的完整管线
bash
源码注解(swagger:meta / swagger:operation / swagger:response)
│ go-swagger scan
▼
templates/swagger/v1-input.json(手写骨架:info、basePath、tags)
│ swagger generate spec --enable-allof-compounding --skip-enum-desc
▼
templates/swagger/v1-swagger.generated.json(OAS2,537 operations)
│ go run build/generate-openapi.go(openapi3gen 转换)
▼
templates/swagger/v1-openapi3.generated.json(OAS3)
│ 运行时
▼
/swagger.v1.json、/openapi3.v1.json(占位符替换后)
│
▼
/api/swagger 页面(openapi-viewer,data-source=/openapi3.v1.json)
(管线依据 Makefile 的 generate-swagger/generate-openapi3 目标与 routers/web/swagger_json.go、routers/web/web.go:1780 路由整理。)
Makefile 变量 (Makefile:141-151 区域):
makefile
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6
SWAGGER_EXCLUDE := gitea.dev/sdk
SWAGGER_SPEC_INPUT := templates/swagger/v1-input.json
SWAGGER_SPEC := templates/swagger/v1-swagger.generated.json
OPENAPI3_SPEC := templates/swagger/v1-openapi3.generated.json
(节选自 Makefile。)
三个关键点:
- 工具版本钉死 :
@v0.36.6写在变量里------go-swagger 的 spec 输出格式随版本漂移,钉版本保证 CI 与本地生成 diff 干净。 SWAGGER_EXCLUDE := gitea.dev/sdk:SDK 包被排除在扫描外,否则它的辅助类型会污染 definitions。- 输入是双源 :
v1-input.json提供骨架(info.version = 0.0.0+GITEA-API-APP-VERSION、basePath = /GITEA-API-APP-SUBURL/api/v1------两个占位符!),源码注解提供 operations。占位符在运行时替换,见 9.11.3。
9.11.2 make 目标族与质量门禁
Makefile:227-264 附近的 targets:
| 目标 | 命令实质 | 失败条件 |
|---|---|---|
generate-swagger |
swagger generate spec --enable-allof-compounding --skip-enum-desc -o $(SWAGGER_SPEC) |
任何 warning 即失败(warning 当 error 处理) |
swagger-check |
生成后 git diff --exit-code $(SWAGGER_SPEC) |
提交的 spec 与源码注解不同步 |
swagger-validate |
swagger validate $(SWAGGER_SPEC) |
WARNING 级别即 exit 1 |
generate-openapi3 |
go run build/generate-openapi.go |
转换失败 |
openapi3-check |
生成后 git diff |
OAS3 产物不同步 |
lint-swagger |
pnpm exec spectral lint -q -F hint $(SWAGGER_SPEC) $(OPENAPI3_SPEC) |
spectral 规则违规 |
(目标行为依据 Makefile:227-313 区域实际内容整理。)
warning 当 error 是这条链路最值得注意的设计。go-swagger 的 validate 对很多问题只发 WARNING(比如响应引用未定义、参数重复),默认不失败------Gitea 显式抬高了阈值。这意味着改了 handler 但忘了更新 swagger 注解,或者注解写错,CI 直接红 。文档不是"尽力而为"的产物,是编译期契约的一部分。
--enable-allof-compounding 这个旗标影响 schema 生成方式:go-swagger 默认遇到 allOf 组合会展开,这个旗标保留/压实组合结构------它与 modules/structs 里大量嵌入结构体(api.Repository 嵌 api.Owner)的表示有关。--skip-enum-desc 跳过 enum 描述提取(加速 + 避免某些枚举的注释噪音)。
9.11.3 运行时占位符替换
生成的 JSON 里躺着两个字面占位符:
json
"info": { "version": "0.0.0+GITEA-API-APP-VERSION" },
"basePath": "/GITEA-API-APP-SUBURL/api/v1"
routers/web/swagger_json.go 在运行时替换:
go
// 逻辑还原
data := strings.ReplaceAll(generatedJSON, "0.0.0+GITEA-API-APP-VERSION", template.JSEscapeString(setting.AppVer))
data = strings.ReplaceAll(data, "/GITEA-API-APP-SUBURL/api/v1", template.JSEscapeString(setting.AppSubURL)+"/api/v1")
(骨架还原,以 routers/web/swagger_json.go 实际代码为准。)
为什么要运行时替换? 因为版本号与子路径是部署时才知道 的:同一个二进制可能跑在 https://example.com/ 也可能跑在 https://example.com/gitea/(AppSubURL),版本随构建变化。如果生成时固化,每个构建都要重新生成 spec(破坏 swagger-check 的可复现性)。构建期生成静态文档 + 运行期注入部署参数,两全。
template.JSEscapeString 转义是为了防 JSON/JS 注入------如果管理员把 APP_URL 配成含 </script> 的字符串(理论上),直接拼进 /api/swagger 页面会破坏 HTML。这是配置值进文档页场景的 XSS 防御,别处少见但这里必须有。
路由注册在 routers/web/web.go:1780-1781(注意:在 web 路由里,不在 API 组里):
bash
GET /swagger.v1.json → SwaggerV1Json
GET /openapi3.v1.json → OpenAPI3Json
GET /api/swagger → 渲染 swagger/openapi-viewer 模板(data-source=/openapi3.v1.json)
/api/swagger 虽然路径以 /api/ 开头,但它是 web 页面 (HTML 查看器),不是 REST 端点------所以它走 web 路由与 web context。路径前缀不能推断协议,这是读 Gitea 路由时容易踩的坑。
SwaggerURL 配置(modules/setting/api.go)默认 AppURL + "api/swagger";EnableSwagger = true 默认开,关掉后 /swagger.v1.json 路由不再注册(routers/web/web.go 里按开关条件注册)。
9.11.4 spectral 规则集
.spectral.yaml 继承 spectral:oas 官方规则集,然后显式关闭了四条:
yaml
extends: spectral:oas
rules:
info-contact: off # info.contact 必填------Gitea 不填维护者联系方式
oas2-api-host: off # OAS2 要求 host 字段------Gitea 用 basePath 相对部署
oas2-parameter-description: off # 每个参数都要 description------维护成本过高
operation-description: off # 每个 operation 要 description------swagger 注解的摘要即描述
(规则名依据 .spectral.yaml 实际内容。)
关掉的四条都是**"文档完备性"而非"文档正确性"**规则------关闭它们不掩盖结构错误,只是承认"537 个 operation 全写 description 不现实"。而保留的规则(operation-4xx-response、oas3-api-servers、typed enums 等)会抓真问题:比如"4xx 响应没有对应错误 schema" ------Gitea 的 149 个 response 里大量是 error 引用,规则确保每个 4xx 都有 $ref。
-F hint 参数把 hint 级别也当失败------比官方默认(warning 起)更严。三层门禁(go-swagger validate WARNING 即失败 → git diff 防漂移 → spectral hint 即失败)叠加,构成 9.11.2 说的"文档即契约"。
9.11.5 537 与 512 的差异
统计口径:swagger:operation 注解 537 处,func(ctx *context.APIContext) 签名的 handler 512 个。差值 25 的来源:
- 一个 handler 多个注解 :少数端点同一函数服务两个路径(如
GetContents的两个 URL 形态、Search的 alias 路径),注解各写一份。 - 辅助函数带注解但非路由目标 :
getGitRefsInternal被两个导出 handler(单 refs/多 refs 路径)调用,注解可能写在辅助层。 - 部分 handler 无注解 :内部/实验端点或漏写------
swagger-check只保证"已写注解与 spec 同步",不强制"每个 handler 必有注解"。
这个差异本身是文档覆盖率的量化指标:512 个可路由 handler 对 537 个注解,覆盖率接近但不等于 100%。要精确审计需要逐 handler 对照------本章给出方法(grep 两个计数),不给虚假的精确结论。
9.11.6 源码级问答:为什么不用 swaggo/swag(国内常见)而用 go-swagger?
三个源码层面的答案:
- 生态位 :Gitea 的 API 结构体在
modules/structs,是独立于 routers 的公共契约包 ------gitea.dev/sdk(被 SWAGGER_EXCLUDE 排除的那个)也依赖它。go-swagger 的swagger:struct/swagger:model注解体系能直接给 structs 包打标,生成 definitions;swaggo 的注解习惯是贴在 handler 上,对"结构体即契约"的组织方式支持弱一些。 - validate 与 lint 集成 :go-swagger 有独立的
validate命令(9.11.2 的 WARNING 门禁依赖它),swaggo 生态的校验要另配工具链。 - 历史路径依赖 :Gitea 的 swagger 注解(
swagger:operation四段式)从 2017 年(gogs 血统更早)沿用至今,537 个注解是存量资产,迁移工具的成本远超收益。
对读者的实际意义:看到 // swagger:operation GET /xxx tag opId 这种格式,就知道工具链是 go-swagger,make generate-swagger 用的是 SWAGGER_PACKAGE 钉的版本------换工具意味着重写 537 个注解块。
9.12 API 路由分组总表与端到端调用链
9.12.1 资源 → 文件 → 方法总表
下表按 routers/api/v1/ 的子包组织,方法数为该文件内 swagger:operation 注解数(合计 537)与 handler 数(合计 512)的分布,端点列为主要资源与代表方法:
| 资源组(tag) | 主文件(routers/api/v1/ 下) |
代表端点 | 主要方法 |
|---|---|---|---|
| repository | repo/repo.go |
GET /repos/{owner}/{repo}、GET /repos/search |
Get、Search、GetByID |
repo/issue.go |
GET/POST /repos/{owner}/{repo}/issues、/{index} |
ListIssues、CreateIssue、EditIssue、Delete | |
repo/issue_comment.go |
/{index}/comments、/comments/{id} |
ListComments、CreateComment、EditComment | |
repo/label.go、repo/milestone.go |
/{index}/labels、milestones |
ReplaceLabels、CreateMilestone | |
repo/assignee.go、repo/issue_subscription.go |
/{index}/assignees、subscriptions |
ListAssignees、AddAssignees | |
repo/pull.go、repo/pull_review.go |
/{index}/pulls、pulls/{index}/reviews |
ListPullRequests、CreatePullRequest、CreateReview | |
repo/branch.go、repo/tag.go、repo/git_ref.go |
branches、tags、git/refs/{ref} |
ListBranches、ListTags、GetGitRefs | |
repo/file.go、repo/commits.go、repo/compare.go |
contents、contents-ext、commits、compare |
GetContents、GetContentsExt、CreateFile(POST contents)、UpdateFile(PUT)、DeleteFile、ChangeFiles、ListCommits | |
repo/blob.go、repo/tree.go、repo/patch.go |
git/blobs、git/trees(贴 git_blobs/git_trees 注解的文件即这两个) |
GetBlob、GetTree、GetPatch | |
repo/release.go、repo/attachment.go |
releases、releases/assets、issues/{index}/assets |
ListReleases、CreateRelease、UploadAsset | |
repo/collaborators.go、repo/key.go |
collaborators、keys |
ListCollaborators、AddCollaborator、ListDeployKeys | |
repo/hook.go、repo/git_hook.go |
hooks、settings/hooks、hooks/git |
ListHooks、AddHook、EditGitHook | |
repo/transfer.go、repo/issue_dependency.go |
transfer、/{index}/dependencies |
Transfer、ListIssueDependencies | |
repo/wiki.go、repo/fork.go、repo/mirror.go、repo/migrate.go、repo/topics.go、repo/status.go、repo/star.go、repo/subscriber.go |
wiki、forks、mirror-sync、topics、statuses、stargazers、watchers |
各资源的读写方法 | |
repo/issue_*.go 九个文件 |
reactions、pins、locks、tracked_times、stopwatches、labels(/{index}/labels) |
对应 issue 子资源 CRUD | |
| user | user/user.go |
GET /user、GET /users/{username} |
GetCurrentUser、GetUser |
user/repo.go |
GET /user/repos、/users/{username}/repos、/orgs/{org}/repos |
ListMyRepos、ListUserRepos、ListOrgRepos | |
user/key.go、user/gpg_key.go、user/email.go |
keys、gpg_keys、emails |
ListKeys、AddKey、ListEmails | |
token/token.go(独立小包) |
GET /user/tokens、/user/tokens/{id} |
GetCurrentToken、DeleteCurrentToken | |
user/star.go、user/watch.go、user/follower.go |
star、subscriptions、followers |
Star、IsStarred、ListWatchers | |
user/settings.go、user/hook.go、user/block.go、user/app.go、user/runners.go、user/action.go |
user/settings/*、user/settings/applications、user/block、runners |
各账户设置类端点 | |
| organization | org/org.go、org/member.go、org/label.go、org/hook.go、org/block.go |
GET /orgs/{org}、/orgs/{org}/members、/orgs/{org}/labels、/orgs/{org}/hooks、/orgs/{org}/blocks |
GetOrganization、ListOrgMembers、AddLabel |
org/team.go |
/orgs/{org}/teams、/teams/{id}/members、/teams/{id}/repos |
ListTeams、AddTeamMember、AddTeamRepository(团队成员与仓库操作都在这一个文件) | |
| notification | notify/ 包:notifications.go、threads.go、repo.go、user.go |
GET /notifications、/notifications/threads/{id}、/repos/{o}/{r}/notifications |
ListNotifications、MarkNotifications、ReadThread |
| admin | admin/ 十一个文件:user.go、org.go、repo.go、hooks.go、email.go、cron.go、adopt.go、packages.go、runners.go、user_badge.go、action.go |
GET /admin/users、/admin/orgs、/admin/collaborators、/admin/hooks、/admin/crons、/admin/unadopted |
CreateUser、DeleteUser、SearchUsers、RunCron、ListUnadopted |
| miscellaneous | misc/ 六个文件:version.go、signing.go、markup.go、licenses.go、gitignore.go、label_templates.go |
GET /version、/signing-key、/markup、/licenses、/gitignore/templates、/label/templates |
Version、GetSigningKey、RenderMarkup |
| package | packages/package.go |
GET /packages/{type}/{name}、/orgs/{org}/packages、/users/{u}/packages |
ListUserPackages、ListOrgPackages、DeletePackage |
| settings | settings/settings.go |
GET/POST /repos/{o}/{r}/settings/... 与服务器默认设置类端点 |
GetRepoSettings 等 |
| (归入 issue/repository tag) | shared/project.go、repo/actions_run.go、repo/action.go、activitypub/person.go |
/projects/{id}、Actions 运行/工件、ActivityPub inbox/outbox |
各自方法 |
| (辅助包,不挂 tag) | shared/:runners.go、action.go、block.go、project.go |
被 user/admin/org/repo 复用的内部逻辑 | --- |
| swagger | swagger/ 十五个文件,纯 response 定义,不注册路由 |
--- | --- |
(文件清单与方法依据 routers/api/v1/ 目录实际文件与 swagger:operation 注解整理;个别文件名以仓库实际为准------如 misc 目录已确认为 version/signing/markup/licenses/label_templates/gitignore 六个文件。)
三点阅读提示:
- 文件即资源 :
routers/api/v1/的包结构与 swagger tag 高度对应(repo包对repositorytag、user包对usertag),找端点先按资源猜包名基本不会错。 swagger/包不注册路由 :它只有swagger:response定义,是纯文档支撑------15 个文件贡献了 149 个 response 的大部分。- misc 的六个小文件合计才是
miscellaneoustag :version/signing/markup/licenses/label_templates/gitignore,每个文件一两个端点,是Routes()开头那组平铺注册的来源。9 个 tag 的完整清单以templates/swagger/v1-input.json为准:admin、issue、miscellaneous、notification、organization、package、repository、settings、user------注意没有独立的project/actionstag,它们的端点被归到issue/repository之下。
9.12.2 端到端调用链:GET /repos/{owner}/{repo}/issues
选列表端点而非单资源端点,因为它能覆盖 scope、assignment、分页、indexer、convert 全链路:
把这张图和 9.6.5 的管线图对照,能看到一个规律:8 个执行单元里有 6 个是中间件,handler 自己只做"取参数 → 查 → 转 → 输出"四步 。API 层的厚度几乎全在中间件链上------这正是第 3 章讲的 AfterRouting + 组中间件架构在 API 上的完全体。
9.12.3 全章回看:三个层次的分工
第 9 章的 537 个端点、512 个 handler,抽象到最后只有三层分工:
- 中间件层决定"你能不能做"------认证(9.3)、scope(9.4)、checkers(9.5)在 handler 之前把 401/403/404/423 全部拦完。handler 里几乎看不到权限判断(除了 deadline 这类细粒度例外)。
- handler 层决定"怎么取参数、调哪个服务" ------bind(9.6.1)、
GetListOptions、ResolveRefCommit把 HTTP 世界翻译成 service 层世界;APIErrorAuto把 service 错误翻译回 HTTP 状态码。 - convert 层决定"你能看到什么" ------数据库模型到 API 模型的单向阀门(9.8),
signed/authed/permission三个输入决定字段裁剪。
三层各自有独立的契约载体:中间件层的契约是 swagger 注解里的状态码列表 ,handler 层的契约是 请求/响应 schema ,convert 层的契约是 modules/structs/api.go 的结构体 。而 OpenAPI(9.11)把三者焊成一份文档,make swagger-check 保证焊点不脱。
理解了这三层,Gitea REST API 的任何端点都可以用同一套方法读:先看 Routes() 里它挂了哪些组中间件(权限与 scope),再看 handler 的 bind 与 service 调用(业务),最后看 convert 调用(表示)。本章的 12 节就是这套方法的 12 个侧面;第 10 章进入 Actions 与 Webhook 时,会看到同样的三层结构在另外两个协议面上的变体。