Skip to content

中间件设计

本文档基于以下源文件编写:

目录

  1. 中间件栈概览
  2. 压缩中间件
  3. 访问日志
  4. Panic 捕获与恢复
  5. 请求 ID
  6. CORS 配置
  7. JWT 认证中间件
  8. 全局 HTTP 代理
  9. SSRF 防护

1. 中间件栈概览

Songloft 后端基于 Chi v5 路由框架,中间件在 setupBaseRouter 中按顺序注册,请求从上到下依次经过每层中间件,响应则反向返回。

                        ┌─ 入站请求 ─┐
                        │            │
                   ┌────▼────────────▼────┐
                   │   Compress (gzip)    │  ← 响应压缩
                   ├──────────────────────┤
                   │   AccessLog (slog)   │  ← 结构化访问日志
                   ├──────────────────────┤
                   │   Tracely Panic 上报  │  ← panic 异常上报
                   ├──────────────────────┤
                   │   Recoverer          │  ← panic 恢复,返回 500
                   ├──────────────────────┤
                   │   RequestID          │  ← 注入唯一请求 ID
                   ├──────────────────────┤
                   │   CORS               │  ← 跨域策略校验
                   └────────┬─────────────┘

              ┌─────────────┼─────────────┐
              │             │             │
         公开路由       认证路由组     插件路由组
       (login/health)  (AuthMiddleware) (AuthMiddleware
                                       + PublicPathChecker)

全局中间件对所有请求生效;AuthMiddleware 仅在需要认证的路由组中通过 r.Group + r.Use 注入。

章节来源


2. 压缩中间件

压缩分为两层:Chi 内置 gzip 中间件处理动态响应,precompressedFS 处理构建时已预压缩的静态资源。

2.1 Chi gzip 中间件

通过 chi_middleware.Compress(5, ...) 注册,压缩等级 5(速度与压缩率平衡),仅对以下 MIME 类型生效:

text/htmltext/csstext/plaintext/javascriptapplication/javascriptapplication/jsonapplication/wasmimage/svg+xmlfont/otf

音频文件(audio/*)和已压缩图片(image/png 等)不在列表中,避免无效压缩。

2.2 预压缩静态资源(precompressedFS)

构建时若安装了 brotli CLI,Makefile 会为前端静态资源生成 .br.gz 预压缩文件。newPrecompressedFS 在启动时从 embed.FS 中加载这些文件到内存。

请求处理逻辑:

  1. 优先检查 Accept-Encoding 是否包含 br,命中则返回 brotli 压缩版本
  2. 其次检查 gzip,命中则返回 gzip 版本
  3. 均不支持时从 embed.FS 读取原始文件
  4. 预压缩缓存未命中时,fallback 到 http.FileServer + Chi gzip 中间件

ETag 使用 CRC32 校验和生成,支持 If-None-Match 条件请求返回 304。addCustomEntry 用于运行时修改过的文件(如 base-path 注入后的 index.html)重新压缩并替换缓存。

章节来源


3. 访问日志

访问日志通过 slogLogFormatter 桥接到标准库 slog,替代 Chi 默认的 chi_middleware.Logger(后者直接写 log 包,不受运行时日志等级控制)。

日志字段

字段说明
methodHTTP 方法(GET/POST 等)
path请求路径
status响应状态码
bytes响应体字节数
dur_ms请求耗时(毫秒,微秒精度)
remote客户端远程地址
request_idChi RequestID 中间件注入的唯一 ID(可选)

日志级别为 Info,当管理员通过 /settings/log-level 将运行时等级调到 WarnError 时,访问日志自动静默。Panic 事件使用 Error 级别记录,附带完整调用栈。

章节来源


4. Panic 捕获与恢复

Panic 处理分为两层,注册顺序至关重要:

  1. Tracely 上报(外层)-- 在 defer recover 中捕获 panic,向 Tracely 错误追踪服务上报 ErrorPayload(包含 type、message、stack、URL),然后重新 panic
  2. Chi Recoverer(内层)-- 捕获重新抛出的 panic,向客户端返回 500 响应,防止进程崩溃

这种设计确保异常既被上报到监控系统,又不会泄露到客户端。

章节来源


5. 请求 ID

通过 chi_middleware.RequestID 为每个请求注入唯一标识符,写入请求上下文。访问日志中间件从上下文提取该 ID 写入 request_id 字段,用于分布式追踪和日志关联。


6. CORS 配置

CORS 中间件使用 go-chi/cors 包,通过 AllowOriginFunc 自定义来源校验:

来源规则匹配范围
http://localhost:*本机开发(任意端口)
http://127.0.0.1:*本机回环(任意端口)
http://192.168.* / http://10.* / http://172.16.*局域网段(仅 HTTP)
http(s)://hanxi.cc(:port)项目主域名(HTTP/HTTPS,任意端口)
http(s)://*.hanxi.cc(:port)所有子域名(HTTP/HTTPS,任意端口)

其他配置项:

  • AllowedMethods: GET、HEAD、POST、PUT、DELETE、OPTIONS
  • AllowedHeaders: Accept、Authorization、Content-Type
  • AllowCredentials: true(允许携带 Cookie/Authorization)
  • MaxAge: 300 秒(预检请求缓存 5 分钟)

章节来源


7. JWT 认证中间件

AuthMiddleware 是路由组级别的认证守卫,保护所有需要登录的 API 端点。

7.1 Token 提取策略

按优先级从两个位置提取 JWT:

  1. Authorization Header -- Bearer <token> 格式,标准 HTTP 认证方式
  2. Query Parameter 回退 -- ?access_token=<token>,用于无法自定义请求头的场景(<img> 标签、CachedNetworkImage、音频流 URL 等)

7.2 XiaoAi Quirk

小爱音箱固件存在一个已知缺陷:会将 URL 中的 & 替换为空格,导致 access_token 后的查询参数被合并进 token 值。中间件对此做了兼容处理:

  1. 检测 access_token 值中是否包含空格(合法 JWT 不含空格)
  2. 按空格拆分,第一段作为真实 token
  3. 后续段按 key=value 格式解析,还原到 r.URL.Query()
  4. 重新编码 r.URL.RawQuery,保证下游 handler 拿到正确的查询参数

7.3 公开路径绕过(PublicPathChecker)

PublicPathChecker 是一个接口,实现者通过 IsPublicPath(path) bool 声明哪些路径无需认证。在中间件入口处,所有注册的 checker 依次检查请求路径,任一返回 true 则跳过 JWT 校验直接放行。

当前使用场景:JS 插件管理器实现了该接口,插件 manifest 中声明的 publicPaths(如 Subsonic /rest/* 兼容端点)在运行时注册为公开路径,支持热更新无需重启。

7.4 认证失败响应

统一返回 JSON 格式 {"error": "<message>", "detail": "<err>"} 配合 HTTP 401 状态码,符合项目 API 响应规范。

章节来源


8. 全局 HTTP 代理

internal/httputil/proxy.go 提供进程级的 HTTP 代理配置,所有通过 httputil.NewClient 创建的客户端自动使用代理。

核心组件

组件说明
proxyConfig线程安全的代理 URL 持有者,sync.RWMutex 保护读写
sharedTransport全局共享的 *http.Transport,连接池配置:最大空闲连接 100、单主机最大 10、空闲超时 90 秒
SetGlobalProxy(rawURL)设置代理地址(支持 HTTP/HTTPS/SOCKS5),空字符串清除代理;调用后 CloseIdleConnections 清理旧连接
GetGlobalProxy()获取当前代理地址
NewClient(timeout)创建使用全局代理的 *http.Client

Loopback 自动旁路

proxyFunc 在每次请求时检查目标主机名:localhost127.0.0.1::1 三个回环地址直接返回 nil(不走代理),避免内部请求被代理拦截。

URL Basic Auth 提取

httputil.ApplyBasicAuthFromURL 是一个辅助函数:从 req.URL.User 中提取用户名和密码,设置到 Authorization 请求头,然后清除 req.URL.User,防止凭证泄露到日志中。用于代理 URL 中嵌入认证信息的场景。

章节来源


9. SSRF 防护

services.IsHostnameAllowed 采用内网封禁策略:阻止访问所有内网地址以防止 SSRF 攻击,允许所有外网域名。该函数用于 HLS 代理等需要服务端发起外部请求的场景。

检查流程

  1. 主机名黑名单 -- localhost*.local、空字符串直接拒绝
  2. DNS 解析 -- 对域名执行 net.LookupIP,解析失败则放行(交由后续 HTTP 请求自行报错)
  3. IP 地址分类检查 -- 对解析出的每个 IP 逐一判断:
检查项覆盖范围
IsLoopback127.0.0.0/8::1
IsPrivate10.0.0.0/8172.16.0.0/12192.168.0.0/16fc00::/7
IsLinkLocalUnicast/Multicast169.254.0.0/16fe80::/10
IsUnspecified0.0.0.0::

任一 IP 命中内网/保留地址范围即拒绝,全部通过才放行。

章节来源