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

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

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

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

三个约束:

  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 查用户"复杂,因为要处理三类安全检查:

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

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

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

四个细节:

  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 声明前):

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

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

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

端到端链:

  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 ------ 写资源

路由注册:

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

三个要点:

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

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 模型,且只搬允许搬的。它解决四类问题:

  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

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

字段 条件 理由
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.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:

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

  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)在请求用了弃用认证方式时加头:

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

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

  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 的注释给出了设计哲学的直接线索:

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

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

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

三条防御线清晰分层:

  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 的完整管线

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

三个关键点:

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

  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/ 下) 代表端点 主要方法
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 六个文件。)

三点阅读提示:

  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 时,会看到同样的三层结构在另外两个协议面上的变体。

相关推荐
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第8章 Pull Request 全生命周期:Gitea 最复杂的子系统
git·开源
TTATTC1 小时前
告别开源 Agent 的本地运维折磨:拆解阿里 QoderWork 的沙盒隔离架构与三级渐进式 Skills 编排实践
docker·开源
除不掉的灰色1 小时前
git回退提交前
git
武雄(小星Ai)1 小时前
Hermes Agent 周报:v0.21.6 发布,多账号连接器上线,6 项安全修复
开源·llm·周报·ai agent·hermes
认真恰饭2 小时前
Git push时警告邮箱暴露,推送失败
git
程序员老赵2 小时前
Docker 部署 Redmine:轻松搭建私有化项目管理平台
docker·容器·开源
银河麒麟操作系统3 小时前
中国操作系统创新技术成果亮相国际开源顶会
开源
A心有千千结4 小时前
通过 SSH 反向代理让服务器借用本机网络访问 GitHub 的最佳实践
linux·服务器·网络·git·ssh·github
SL_staff4 小时前
计息规则不再等IT:JVS-Rules函数计算器在金融计息场景的技术实践
java·git·全栈