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/html、text/css、text/plain、text/javascript、application/javascript、application/json、application/wasm、image/svg+xml、font/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 将运行时等级调到 Warn 或 Error 时,访问日志自动静默。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 在每次请求时检查目标主机名:localhost、127.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/8、172.16.0.0/12、192.168.0.0/16、fc00::/7
IsLinkLocalUnicast/Multicast169.254.0.0/16、fe80::/10
IsUnspecified0.0.0.0、::

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

章节来源