Gitea源码深度剖析-第9章 REST API v1 体系

10 阅读50分钟

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.tmpltemplates/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 路由同一行风格:

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 行,是全仓库最大的单个函数。它的骨架是四层结构,从外到内:

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() 函数体的主体。全部资源组包在一个最外层组里:

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 为例,中间件是叠洋葱:

/ 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 调用抽出来看,模式高度统一:

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/reqSignInRequiredreqToken
错误出口ctx.NotFound/HTML 模板ctx.APIError* → JSON 信封
输出渲染templates.RenderHTML 或 ctx.Redirectctx.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 的核心结构:

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() 按顺序做六件事:

  1. NewBaseContext —— 从 http.Request/ResponseWriter 构造 *Base,初始化 ctx.Data(map)、ctx.Data["Locale"] 等。
  2. 缓存上下文 —— ctx.Cache,用于同一请求内传递可选值。
  3. Repo 上下文 —— 初始化 ctx.Repo 子结构(此时 Repository 还是 nil,等 repoAssignment 填)。
  4. Org 上下文 —— 同上。
  5. SetContextValue —— 往 chi 的 context 里塞 Gitea 自己的键值。
  6. 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?

三个原因,都能在上面的结构里看到:

  1. 字段精简:context.Context 带 Render、PageData、Session、Flash 等 web 专属依赖;API 全用不上,带上反而诱导 handler 去渲染 HTML。
  2. 独有字段:IsBasicAuth、PublicOnly 是 API 专属概念,塞进 Context 会让 web 侧出现"永远为 false"的噪声字段。
  3. 编译期隔离: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():

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 处理:

// 伪代码还原 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 是认证组的第一棒,它的判断树是全仓库最精巧的分支之一:

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 结构:

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 的实体定义:

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():

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 实际代码为准;省略了错误分支的完整文案。)

三个约束:

  1. 只对管理员生效——非 admin 用 ?sudo= 直接 403,而不是静默忽略。静默忽略会让调用方误以为操作以 sudo 身份执行了。
  2. 必须在认证之后——sudo() 挂在最外层资源组上,而认证是 AfterRouting 钩子;AfterRouting 先于组中间件执行(第 3 章的链顺序),所以 ctx.Doer 已就位。
  3. 必须写审计——audit.Record(...) 记录"谁以谁的身份做了什么"。没有这条审计,sudo 就成了不可追溯的越权通道。

任务清单把 sudo() 列为第 3 章留的钩子之一,这里兑现:它的本质是在权限检查(checkers)之前替换 ctx.Doer,因此后续所有 reqSiteAdmin/reqRepoWriter 判断都以被 sudo 的用户为准。如果把它放在 checkers 之后就毫无意义了——顺序即语义。

9.3.5 Basic 认证与 2FA 的交互

services/auth/basic.go 的 Verify 流程比"解码 header 查用户"复杂,因为要处理三类安全检查:

// 流程还原自 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 不做拦截,只发告警头:

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 层视角)

在源码里,这两者的分工非常机械:

状态码触发函数语义源码位置
401reqToken:ctx.IsSigned 为 false"我不知道你是谁"routers/api/v1/api.go:375
403tokenRequiresScopes:scope 不足"我知道你是谁,但这个 token 权限不够"同文件 :328
403reqSiteAdmin/reqRepoWriter 等 checkers"你知道你是谁,但你的角色不够"同文件各 checker
404repoAssignment:无任何单位访问权"你不该知道这个仓库存在"同文件 :147

reqToken 的实现一句话:

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:

// 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覆盖的资源组
activitypubActivityPub 协议相关
admin站点管理(/admin/*)
misc版本、签名、许可证、gitignore、label 模板等杂项
notification通知
organization组织、团队、成员
package包
issueissue、评论、标签、里程碑、项目
repository仓库、分支、标签、commit、raw、release、hook、协作
user用户、密钥、令牌、邮箱、gpg、star、watch、跟随

每个 category 有三个级别,用 AccessTokenScopeLevel 表示:

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 执行的第一层:

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 的三个函数:

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 全链路时序

sequenceDiagram
    participant C as 客户端
    participant A as apiAuth(OAuth2)
    participant S as AuthShared
    participant G as tokenRequiresScopes
    participant P as checkTokenPublicOnly
    participant H as handler

    C->>A: Authorization: Bearer 
    A->>A: parseToken → GetAccessTokenBySHA
    A->>A: sha256 + 常量时间比较
    A->>S: userFromToken 写 store["ApiTokenScope"]
    S->>S: ctx.Doer / ctx.IsSigned 就位
    S->>G: 进入 /repos 组
    G->>G: GetRequiredScopes(GET, repository)=Read
    G->>G: token.HasScope(repository, Read)?
    alt scope 不足
        G-->>C: 403 {"message":"..."}
    else 通过
        G->>P: repoAssignment 后
        P->>P: ctx.PublicOnly = token.PublicOnly?
        P->>H: 放行(可能附加公开过滤)
        H-->>C: 200 + convert 层输出
    end

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条件
reqSiteAdminctx.Doer.IsAdmin
reqSelfOrAdmin是本人或是 admin
reqExploreSignIn探索页要求登录
reqUsersExploreEnabledsetting.Explore 用户页开关

C. 仓库角色族(拒绝 → 403,需已过 repoAssignment):

checker条件
reqRepoWriterpermission.CanWriteany(unit)(任一单位可写)
reqRepoReader至少一个单位有读权限
reqAnyRepoReader同上,变体
reqOwner仓库 owner 本人
reqRepoDangerZoneowner 或站点 admin(危险区操作)

D. 组织/团队族(拒绝 → 403):

checker条件
reqOrgOwnership组织 owner
reqOrgMembership组织成员
reqOrgVisible组织对当前用户可见
teamAccessPrivileged + denyNonTeamMember团队管理权限
reqTeamReadAccess / reqTeamMembership团队读/成员

E. 资源状态族(拒绝常是 404 或 423,因为"资源不可用"不算权限问题):

checker拒绝状态码语义
mustEnableIssues404仓库没开 issue 功能
mustAllowPulls404没开 PR
mustEnableIssuesOrPulls404两者都没开
mustEnableWiki404没开 wiki
mustNotBeArchived423 repo is archived仓库已归档,只读
mustEnableEditor423编辑器禁用
mustEnableAttachments404附件功能关闭
reqGitHook404git hook 功能禁用(通常 admin-only)
reqWebhooksEnabled / reqStarsEnabled / reqProjectsUnitAccess404对应单位未启用

(各 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 完整状态码决策树

flowchart TD
    A[请求进入] --> B{已认证?}
    B -- 否 --> C{端点匿名可访问?}
    C -- 否 --> D[&#34;401 token is required<br/>reqToken&#34;]
    C -- 是 --> E[继续]
    B -- 是 --> F{token scope 覆盖<br/>该 category + method?}
    F -- 否 --> G[&#34;403 scope insufficient<br/>tokenRequiresScopes&#34;]
    F -- 是 --> H{public-only token<br/>打非公开资源?}
    H -- 是 --> I[&#34;404(信息隐藏)<br/>checkTokenPublicOnly / TokenCanAccessRepo&#34;]
    H -- 是(端点语义不可过滤) --> I2[&#34;403 rejectPublicOnly&#34;]
    H -- 否 --> J[&#34;repoAssignment:<br/>资源存在且可见?&#34;]
    J -- 否 --> K[&#34;404(不该知道存在)&#34;]
    J -- 是 --> L{角色/成员 checkers<br/>reqRepoWriter 等}
    L -- 否 --> M[&#34;403 角色不足&#34;]
    L -- 是 --> N{资源状态 checkers<br/>mustEnable* / mustNotBeArchived}
    N -- 功能未启用 --> O[&#34;404 功能不存在&#34;]
    N -- 已归档/编辑禁用 --> P[&#34;423 Locked&#34;]
    N -- 通过 --> Q[handler 执行]

注意图里 checkTokenPublicOnly 有两条出边——能过滤就过滤(404/缩小结果集),不能过滤才拒绝(403)。这个"优先过滤、退化才拒"的原则是 scope 与可见性代码的共同取向:GET /repos 对 public-only token 返回公开仓库子集(过滤),而不是 403(拒绝)。

9.5.4 assignment 家族:repoAssignment 的七步

repoAssignment()(routers/api/v1/api.go:147 起)是 API 里最重的单个 checker,完整步骤:

  1. 解析 owner:ctx.Params("owner"] → user_model.GetUserByUsername,支持 LookupUserRedirect(用户改名后的旧名重定向)。
  2. 解析 repo:repo_model.GetRepositoryByOwnerAndName,支持 LookupRedirect(仓库转移后的旧路径重定向)。
  3. 2FA 强制检查 doerNeedTwoFactorAuth:如果实例策略强制 2FA 而当前 Doer 没启用,直接把 Permission 降为 PermissionNoAccess——效果等同于看不见仓库。这里有一个全仓库唯一的单测 TestDoerNeedTwoFactorAuth(routers/api/v1/api_test.go),验证 nil、ActionsUser、DeployKeyUser 三种身份都不算"需要 2FA"——即自动化身份不受 2FA 策略限制。
  4. 算权限:GetDoerRepoPermission(ctx, repo, doer)——第 5 章详述过的权限计算入口。
  5. 可见性 404:!permission.HasAnyUnitAccessOrPublicAccess() → 404。
  6. token 可达性 404:!ctx.TokenCanAccessRepo(doer, repo) → 404(public-only 场景)。
  7. 打开 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 没这么做,三个原因都能从代码读出来:

  1. 签名太短:几乎所有 checker 就是 func(*context.APIContext),直接当 chi 中间件用最自然,包一层 interface 反而要多写 20 行适配。
  2. 组合方式不同:tokenRequiresScopes(scopes.User) 是工厂(闭包捕获参数),reqToken 是裸函数,mustEnableIssues 是端点级短路。统一接口要求它们都实现方法,实际只是把三种形态塞进一个形状里。
  3. 错误分支各异:有的发 401、有的 403、有的 404、有的 423,还有的只设 ctx.PublicOnly 不报错(checkTokenPublicOnly 的"放行+标记"语义根本不是 Check 模型能表达的)。

第 3 点是决定性的:checkers 不是"验证器",而是"上下文加工者 + 守门人"的混合体。硬套验证器抽象会把 checkTokenPublicOnly 这类"副作用型"中间件扭曲成返回 error 的函数,丢失"放行但标记"的表达力。


9.6 请求处理管线:bind、Swagger 注解与错误信封

9.6.1 bind[T]:泛型参数绑定

routers/api/v1/api.go:899 的 bind 是 API 请求体进入 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。)

四个细节:

  1. 泛型 + 模板参数 tmpl T:调用方写 bind(api.CreateIssueOption{}),T 由实参推导。tmpl 的值本身不使用,只借它的类型。
  2. BindFormAny 只应用 binding tag,不调 Validate(modules/web/middleware/binding.go,第 3 章对比过 BindFormValidate)。语义分离:binding:"required" 是结构性错误(422),业务规则校验留给 handler(也是 422 但由 handler 精确控制 message)。
  3. 绑定失败 → 422 Unprocessable Entity,message 格式固定为 字段列表: 错误。422 而非 400,因为请求语法是对的(JSON 合法),只是语义字段不满足——RFC 7231 把这种情况划给 422。
  4. 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 声明前):

// 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 文件顶部:

// 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 前缀的结构体:

// 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:

// 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:

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 管线全景

flowchart LR
    A[&#34;HTTP 请求&#34;] --> B[&#34;chi 路由匹配&#34;]
    B --> C[&#34;AfterRouting×5<br/>APIContexter→Audit→<br/>Deprecated→apiAuth→Verify&#34;]
    C --> D[&#34;组中间件<br/>sudo→scope→assignment&#34;]
    D --> E[&#34;bind[T]<br/>422 失败即止&#34;]
    E --> F[&#34;handler<br/>业务逻辑+convert&#34;]
    F --> G[&#34;ctx.JSON / JSONForHTML&#34;]
    G --> H[&#34;SetLinkHeader /<br/>X-Total-Count&#34;]

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 组内):

/repos 组:tokenRequiresScopes(...Repository)
  └ repoAssignment(), checkTokenPublicOnly()
    └ GET /{owner}/{repo} → repo.Get

swagger 注解(routers/api/v1/repo/repo.go):

// swagger:operation GET /repos/{owner}/{repo} repository repoGet
//
// Get a repository
// ...
// responses:
//   "200":
//     "$ref": "#/responses/repository"
//   "404":
//     "$ref": "#/responses/notFound"

(骨架,实际注解还含 500 等响应,以源文件为准。)

handler 本体——短到只有几行,因为重活全在中间件和 convert 层:

// 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。)

端到端链:

  1. chi 匹配 /repos/{owner}/{repo},抽出 owner/repo 参数。
  2. AfterRouting 五个钩子跑完 → ctx.Doer 就位(或匿名)。
  3. sudo():无 ?sudo,穿透。
  4. tokenRequiresScopes(scopes.Repository):GET 方法要求 repository:read;匿名则跳过。
  5. repoAssignment():owner → repo → 2FA 检查 → GetDoerRepoPermission → 可见性 404 → token 可达性 404 → 打开 GitRepo。
  6. checkTokenPublicOnly():public-only 标记。
  7. repo.Get 执行:convert.ToRepo(ctx, repo, permission) 组装 api.Repository(9.8 节拆这个函数)。
  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 —— 写资源

路由注册:

/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——六种状态码对应六类失败,是全仓库注解最丰富的端点之一:

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):

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 判断。)

三个要点:

  1. utils.GetListOptions(ctx):query page/limit → db.ListOptions,convert.ToCorrectPageSize 把 limit 夹到 [1, 50],缺省 30。
  2. 先查后滤 + Private: true:查询把私有仓库也取出来,再按当前用户权限过滤。这意味着分页边界在过滤后才正确——SetLinkHeader/SetTotalCountHeader 用的是过滤后的总数,不是 SQL 的 Count(*)。这类端点必须小心 count 口径,否则客户端会看到 X-Total-Count: 40 但翻遍所有页只凑出 25 条。
  3. 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):

GET /repos/{owner}/{repo}/contents/{path}
GET /repos/{owner}/{repo}/contents          (列根目录)

handler 分两支:路径为空 → GetContentsList,非空 → GetContents,最终都进:

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,这里有个配额检查:

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):

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)有一个懒同步设计:

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)的返回形态最特殊:

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 四个端点的横向对比

维度repoGetCreateIssueListUserReposGetContents
scope categoryrepositoryrepository + issueuserrepository
method 定级readwritereadread
assignmentrepoAssignmentrepoAssignmentuserAssignmentrepoAssignment + ResolveRefCommit
bind无bind[CreateIssueOption]无(query)无(query/path)
错误码404201/403/404/412/422/423200/404200/404/422
convert 入口ToRepoToAPIIssueToRepofiles_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 模型,且只搬允许搬的。它解决四类问题:

  1. 敏感字段物理隔离:user_model.User 有 Password、Salt、LoginName、SourceID、ProhibitLogin——convert.toUser 只在特定条件下才把其中几个搬过去(见 9.8.2)。数据库模型永远不直接进 ctx.JSON。
  2. 权限投影:同一仓库,admin 看和游客看的 api.Repository 字段不同(permission.admin bool),convert 按传入的 Permission 计算。
  3. 懒加载兑现:ToAPIIssue 里 LoadPoster/LoadRepo/LoadLabels/LoadMilestone/LoadAssignees/LoadAttachments 一串加载——数据库模型允许 nil 关联,JSON 不允许"半加载"。
  4. 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

字段按这两个开关分三档:

字段条件理由
Emailsigned && (!user.KeepEmailPrivate || authed)邮箱是隐私;KeepEmailPrivate 关闭时登录用户可见;开启时仅本人/admin 可见
IsAdminauthed站点管理员身份是敏感信息,只告诉本人和 admin
LoginName / SourceID / LastLoginauthed登录来源(LDAP 用户名等)泄露认证体系细节
Language / IsActive / ProhibitLoginauthed账户状态是内部信息
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.Count releases、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:

// 伪代码顺序,依据 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:

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 的版本策略是单版本 + 内部兼容:

  1. 路径永不迁移:/api/v1 是永久承诺,破坏性变更不会悄悄改 v1 的行为,而是走下面三条通道。
  2. 新增不破坏:新字段带 omitempty 加进 api.XXX 结构体;新端点直接加路由。老客户端对未知字段(Go 的 json.Unmarshal 忽略未知字段)和未知路径都无感。
  3. 废弃不删除:字段或端点标记 deprecated 后保留至少一个大版本,期间用告警信号提醒迁移。

这套策略的代价是 modules/structs/api.go 单文件膨胀、Routes() 膨胀到近 1000 行——兼容性是用代码体积换来的。它的收益是 Gitea 的 API 客户端生态(gitea-swagger、各种语言的生成客户端)不需要做版本分支。

9.9.2 三条兼容信号通道

通道一:X-Gitea-Warning 响应头。 checkDeprecatedAuthMethods(9.3.6)在请求用了弃用认证方式时加头:

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 里、到期移除,这是三段式流程。本仓库正处在某个"告警已发出、尚未移除"的时间点上,所以代码里能看到弃用逻辑与被弃用逻辑并存。

对使用方的实操建议(从源码反推):

  1. 监控 X-Gitea-Warning 头,出现即视为 lint 失败。
  2. 读 swagger 文档时过滤 deprecated: true。
  3. 不要依赖 400/403/404 的精确选择(9.7.2 已证明按端点各异),只依赖 swagger 注解里声明的状态码集合。
  4. 反序列化时忽略未知字段,序列化时只依赖 swagger 声明的必填字段。

9.9.5 一个源码级问答:为什么不学 CalVer/日期版本头(如 Accept: application/vnd.github.v3+json)?

GitHub 用 Accept 头做媒体类型版本,因为它的 API 在同一路径下演进了多个不兼容代。Gitea 不需要,两个原因:

  1. 单实现演进:Gitea 的 v1 只有一代,从来没有"同路径两个行为"的时期——没有需求就不引入机制。这是 YAGNI 在 API 设计上的体现。
  2. 媒体类型版本需要每个端点声明支持哪些版本,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 中找)"。我把搜索做全了,结论是一个否定证明:

  1. 全仓库 grep StatusTooManyRequests(429):唯一命中在 pwn 的依赖测试 fixture 里,业务代码零命中。没有任何 handler 或中间件返回 429。
  2. grep rate limit / RateLimit 相关中间件:modules/ 下没有 API 限流中间件。唯一带 "RateLimit" 字样的业务代码是 services/migrations/github.go——那是消费 GitHub API 时处理对方的限流(读到 X-RateLimit-Remaining 为 0 时退避),与 Gitea 自己的 API 无关。它的错误最终在 routers/repo/migrate.go 被转成 422 Remote visit addressed rate limitation.——注意这是迁移功能的业务错误,不是 Gitea 对客户端限流。
  3. 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() 调用。
  4. common.ProtocolMiddlewares() 不含限流——第 3 章列过的七个协议中间件(ChiRoutePathHandler/RequestContextHandler/SecurityHeaders/ForwardedHeaders/RequestInfo/AccessLogger/ViteDev)没有任何令牌桶、漏桶、滑动窗口实现。

所以事实是:Gitea REST API 默认对请求速率不设任何限制。同一个 IP 可以用合法 token 无限速打满 537 个端点。

9.10.2 为什么这是有意为之(从代码反推的设计动机)

QoS 的注释给出了设计哲学的直接线索:

// 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 七个键。配置面就是能力面——没有配置项的功能,功能不存在。

设计动机可以从三个事实推断:

  1. Gitea 是自托管软件:部署者可以在反向代理层(nginx limit_req、Cloudflare)加限流,Gitea 内置反而冲突(代理已限流,应用层再限会双重计算)。自托管软件的惯例是把网络层策略留给网络层。
  2. API 的重端点有自己的配额(见 9.10.3),"限流"被具体化成"每个请求的代价上限"而非"每时间窗的请求数"。
  3. CI/CD 是合法的大流量场景:Git clone 后的 API 调用、Actions 的批量操作天然突发——内置限流最容易误伤的目标就是最忠实的用户。QoS 只保护 web 浏览体验(登录用户优先),API 的批量流量被刻意放行。

9.10.3 真实存在的配额:按请求代价设上限

虽然没有时间窗限流,但每个请求的资源消耗都有硬上限,这才是 Gitea 的"配额体系":

配置项默认值作用点超限行为
setting.API.MaxResponseItems50convert.ToCorrectPageSize静默削到 50(9.6.4)
setting.API.DefaultPagingNum30同上,page/limit 缺省默认页大小
setting.API.DefaultMaxBlobSize10485760(10 MiB)files.GetBlobBySHA200 但 content 字段缺失(9.7.4)
setting.API.DefaultMaxResponseSize104857600(100 MiB)大响应体读取截断
setting.API.DefaultGitTreesPerPage1000git trees 端点每页条目上限
setting.Attachment.MaxSize100(MiB)attachment.uploadAttachment422 attachment exceeds limit
setting.Attachment.MaxFiles5多文件上传超出拒绝
setting.Repository.Release.FileMaxSize(配置)UploadAttachmentForRelease同附件

(默认值依据 modules/setting/api.go 与 modules/setting/attachment.go。)

附件上传的实现值得看(services/attachment/attachment.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。)

三条防御线清晰分层:

  1. 已知 Content-Length → 直接比较 file.size > maxFileSize,连读都不读就拒绝。
  2. 未知大小 → http.MaxBytesReader 包住流,读到超限时 io.Reader 返回 *http.MaxBytesError,Go 标准库会顺带把连接标记为不可复用。这里有个细节:MaxBytesReader 还会在超限时自动给 ResponseWriter 写 413——所以 Gitea 后续把 MaxBytesError 转成自己的 ErrContentTooLarge 时,实际响应可能已经是标准库写好的 413 状态。
  3. 内容白名单 → 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。控制手段:

  1. 分页上限 50——索引查询的窗口被硬性限制。
  2. api.SearchError 包装(routers/api/v1/repo/repo.go 的 Search)——搜索失败不是裸 500,而是转成 api.SearchError{OK bool, Data, Errors []string} 结构。这给了搜索一个部分成功的表达空间(indexer 超时可返回 OK=false + 空 Data 而非整个请求失败)。
  3. utils.ResolveSortOrder——排序字段是白名单解析,非法值 → 422 而不是拼进 SQL。这防的是注入面,也是"输入即配额"的一部分(不合法的排序可能触发全表扫)。

/repos/search 的另一个特点是匿名可访问但受 explore 配置控制(reqUsersExploreEnabled 类似物)——管理员可以关掉公开搜索,此时未登录请求 404/403。可见性开关是比限流更根本的流量开关:关了就一个请求都不会到数据库。

9.10.5 如果你需要限流,源码给的位置

对部署者,从代码结构看三个介入点:

  1. 反向代理层(推荐):Gitea 前置 nginx/caddy,对 /api/v1/ 前缀做 limit_req。Gitea 不会因此行为异常——API 全部走标准 HTTP,无长连接协议(除 SSE 类端点)。
  2. ProtocolMiddlewares 注入:在 routers/common/middleware.go 的协议链里加一个自定义限流中间件。它会同时作用于 web 和 API,需要按 routing.GetRequestRecordInfo(ctx) 或 chi.RouteContext 的 RoutePattern 区分流量(requestPriority 就是这么区分的——strings.HasPrefix(routePattern, "/{username}/{reponame}/") 判 web 页面)。
  3. 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 的完整管线

源码注解(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 区域):

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。)

三个关键点:

  1. 工具版本钉死:@v0.36.6 写在变量里——go-swagger 的 spec 输出格式随版本漂移,钉版本保证 CI 与本地生成 diff 干净。
  2. SWAGGER_EXCLUDE := gitea.dev/sdk:SDK 包被排除在扫描外,否则它的辅助类型会污染 definitions。
  3. 输入是双源: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-swaggerswagger generate spec --enable-allof-compounding --skip-enum-desc -o $(SWAGGER_SPEC)任何 warning 即失败(warning 当 error 处理)
swagger-check生成后 git diff --exit-code $(SWAGGER_SPEC)提交的 spec 与源码注解不同步
swagger-validateswagger validate $(SWAGGER_SPEC)WARNING 级别即 exit 1
generate-openapi3go run build/generate-openapi.go转换失败
openapi3-check生成后 git diffOAS3 产物不同步
lint-swaggerpnpm 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 里躺着两个字面占位符:

"info": { "version": "0.0.0+GITEA-API-APP-VERSION" },
"basePath": "/GITEA-API-APP-SUBURL/api/v1"

routers/web/swagger_json.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 组里):

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 官方规则集,然后显式关闭了四条:

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 的来源:

  1. 一个 handler 多个注解:少数端点同一函数服务两个路径(如 GetContents 的两个 URL 形态、Search 的 alias 路径),注解各写一份。
  2. 辅助函数带注解但非路由目标:getGitRefsInternal 被两个导出 handler(单 refs/多 refs 路径)调用,注解可能写在辅助层。
  3. 部分 handler 无注解:内部/实验端点或漏写——swagger-check 只保证"已写注解与 spec 同步",不强制"每个 handler 必有注解"。

这个差异本身是文档覆盖率的量化指标:512 个可路由 handler 对 537 个注解,覆盖率接近但不等于 100%。要精确审计需要逐 handler 对照——本章给出方法(grep 两个计数),不给虚假的精确结论。

9.11.6 源码级问答:为什么不用 swaggo/swag(国内常见)而用 go-swagger?

三个源码层面的答案:

  1. 生态位:Gitea 的 API 结构体在 modules/structs,是独立于 routers 的公共契约包——gitea.dev/sdk(被 SWAGGER_EXCLUDE 排除的那个)也依赖它。go-swagger 的 swagger:struct/swagger:model 注解体系能直接给 structs 包打标,生成 definitions;swaggo 的注解习惯是贴在 handler 上,对"结构体即契约"的组织方式支持弱一些。
  2. validate 与 lint 集成:go-swagger 有独立的 validate 命令(9.11.2 的 WARNING 门禁依赖它),swaggo 生态的校验要另配工具链。
  3. 历史路径依赖: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/ 下)代表端点主要方法
repositoryrepo/repo.goGET /repos/{owner}/{repo}、GET /repos/searchGet、Search、GetByID
repo/issue.goGET/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、milestonesReplaceLabels、CreateMilestone
repo/assignee.go、repo/issue_subscription.go/{index}/assignees、subscriptionsListAssignees、AddAssignees
repo/pull.go、repo/pull_review.go/{index}/pulls、pulls/{index}/reviewsListPullRequests、CreatePullRequest、CreateReview
repo/branch.go、repo/tag.go、repo/git_ref.gobranches、tags、git/refs/{ref}ListBranches、ListTags、GetGitRefs
repo/file.go、repo/commits.go、repo/compare.gocontents、contents-ext、commits、compareGetContents、GetContentsExt、CreateFile(POST contents)、UpdateFile(PUT)、DeleteFile、ChangeFiles、ListCommits
repo/blob.go、repo/tree.go、repo/patch.gogit/blobs、git/trees(贴 git_blobs/git_trees 注解的文件即这两个)GetBlob、GetTree、GetPatch
repo/release.go、repo/attachment.goreleases、releases/assets、issues/{index}/assetsListReleases、CreateRelease、UploadAsset
repo/collaborators.go、repo/key.gocollaborators、keysListCollaborators、AddCollaborator、ListDeployKeys
repo/hook.go、repo/git_hook.gohooks、settings/hooks、hooks/gitListHooks、AddHook、EditGitHook
repo/transfer.go、repo/issue_dependency.gotransfer、/{index}/dependenciesTransfer、ListIssueDependencies
repo/wiki.go、repo/fork.go、repo/mirror.go、repo/migrate.go、repo/topics.go、repo/status.go、repo/star.go、repo/subscriber.gowiki、forks、mirror-sync、topics、statuses、stargazers、watchers各资源的读写方法
repo/issue_*.go 九个文件reactions、pins、locks、tracked_times、stopwatches、labels(/{index}/labels)对应 issue 子资源 CRUD
useruser/user.goGET /user、GET /users/{username}GetCurrentUser、GetUser
user/repo.goGET /user/repos、/users/{username}/repos、/orgs/{org}/reposListMyRepos、ListUserRepos、ListOrgRepos
user/key.go、user/gpg_key.go、user/email.gokeys、gpg_keys、emailsListKeys、AddKey、ListEmails
token/token.go(独立小包)GET /user/tokens、/user/tokens/{id}GetCurrentToken、DeleteCurrentToken
user/star.go、user/watch.go、user/follower.gostar、subscriptions、followersStar、IsStarred、ListWatchers
user/settings.go、user/hook.go、user/block.go、user/app.go、user/runners.go、user/action.gouser/settings/*、user/settings/applications、user/block、runners各账户设置类端点
organizationorg/org.go、org/member.go、org/label.go、org/hook.go、org/block.goGET /orgs/{org}、/orgs/{org}/members、/orgs/{org}/labels、/orgs/{org}/hooks、/orgs/{org}/blocksGetOrganization、ListOrgMembers、AddLabel
org/team.go/orgs/{org}/teams、/teams/{id}/members、/teams/{id}/reposListTeams、AddTeamMember、AddTeamRepository(团队成员与仓库操作都在这一个文件)
notificationnotify/ 包:notifications.go、threads.go、repo.go、user.goGET /notifications、/notifications/threads/{id}、/repos/{o}/{r}/notificationsListNotifications、MarkNotifications、ReadThread
adminadmin/ 十一个文件:user.go、org.go、repo.go、hooks.go、email.go、cron.go、adopt.go、packages.go、runners.go、user_badge.go、action.goGET /admin/users、/admin/orgs、/admin/collaborators、/admin/hooks、/admin/crons、/admin/unadoptedCreateUser、DeleteUser、SearchUsers、RunCron、ListUnadopted
miscellaneousmisc/ 六个文件:version.go、signing.go、markup.go、licenses.go、gitignore.go、label_templates.goGET /version、/signing-key、/markup、/licenses、/gitignore/templates、/label/templatesVersion、GetSigningKey、RenderMarkup
packagepackages/package.goGET /packages/{type}/{name}、/orgs/{org}/packages、/users/{u}/packagesListUserPackages、ListOrgPackages、DeletePackage
settingssettings/settings.goGET/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 复用的内部逻辑—
swaggerswagger/ 十五个文件,纯 response 定义,不注册路由——

(文件清单与方法依据 routers/api/v1/ 目录实际文件与 swagger:operation 注解整理;个别文件名以仓库实际为准——如 misc 目录已确认为 version/signing/markup/licenses/label_templates/gitignore 六个文件。)

三点阅读提示:

  1. 文件即资源:routers/api/v1/ 的包结构与 swagger tag 高度对应(repo 包对 repository tag、user 包对 user tag),找端点先按资源猜包名基本不会错。
  2. swagger/ 包不注册路由:它只有 swagger:response 定义,是纯文档支撑——15 个文件贡献了 149 个 response 的大部分。
  3. misc 的六个小文件合计才是 miscellaneous tag: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/actions tag,它们的端点被归到 issue/repository 之下。

9.12.2 端到端调用链:GET /repos/{owner}/{repo}/issues

选列表端点而非单资源端点,因为它能覆盖 scope、assignment、分页、indexer、convert 全链路:

sequenceDiagram
    participant C as 客户端
    participant R as chi Router
    participant W as AfterRouting×5
    participant M as 组中间件
    participant H as ListIssues handler
    participant DB as issue_indexer/DB
    participant CV as convert.ToAPIIssue

    C->>R: GET /api/v1/repos/o/r/issues?page=1&limit=20<br/>Authorization: Bearer 
    R->>R: 匹配 /repos/{owner}/{repo}/issues
    R->>W: 分发(toHandlerProvider 反射)
    W->>W: APIContexter:构造 APIContext
    W->>W: AuditOrigin(OriginAPI)
    W->>W: checkDeprecatedAuthMethods(Authorization 头无告警)
    W->>W: apiAuth(OAuth2):parseToken → 含&#34;.&#34;? 否<br/>→ GetAccessTokenBySHA(last-8 索引 + sha256 常量比较)
    W->>W: verifyAuthWithOptionsAPI(GET 非 OPTIONS,穿透)
    W->>W: AuthShared:ctx.Doer / IsSigned 就位
    W->>M: sudo():无 ?sudo,穿透
    M->>M: tokenRequiresScopes(Repository):GET→read,PAT 有 scope ✓
    M->>M: repoAssignment():owner→repo→2FA 检查<br/>→GetDoerRepoPermission→可见性 404 检查→打开 GitRepo
    M->>M: checkTokenPublicOnly():非 public-only,穿透
    M->>M: tokenRequiresScopes(Issue):read ✓
    M->>M: mustEnableIssues():issue unit 开启 ✓
    M->>H: 执行 handler
    H->>H: utils.GetListOptions:page/limit→ToCorrectPageSize(20)
    H->>DB: issue_indexer.SearchIssues(indexer 查询 + count)
    DB-->>H: issue IDs + total
    H->>DB: issues.GetIssuesByIDs(回表取完整 issue)
    H->>CV: ToAPIIssueList(逐个 LoadPoster/Labels/...)
    CV-->>H: []api.Issue
    H->>H: ctx.SetLinkHeader(page, total)、SetTotalCountHeader
    H-->>C: 200 [ ... ] + Link + X-Total-Count

把这张图和 9.6.5 的管线图对照,能看到一个规律:8 个执行单元里有 6 个是中间件,handler 自己只做"取参数 → 查 → 转 → 输出"四步。API 层的厚度几乎全在中间件链上——这正是第 3 章讲的 AfterRouting + 组中间件架构在 API 上的完全体。

9.12.3 全章回看:三个层次的分工

第 9 章的 537 个端点、512 个 handler,抽象到最后只有三层分工:

  1. 中间件层决定"你能不能做"——认证(9.3)、scope(9.4)、checkers(9.5)在 handler 之前把 401/403/404/423 全部拦完。handler 里几乎看不到权限判断(除了 deadline 这类细粒度例外)。
  2. handler 层决定"怎么取参数、调哪个服务"——bind(9.6.1)、GetListOptions、ResolveRefCommit 把 HTTP 世界翻译成 service 层世界;APIErrorAuto 把 service 错误翻译回 HTTP 状态码。
  3. 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 时,会看到同样的三层结构在另外两个协议面上的变体。