SnailBlog 项目总结:一个人机协作的全栈博客从 0 到安全上线
SnailBlog 项目总结:一个人机协作的全栈博客从 0 到安全上线
这是 SnailBlog 的收官总结。一个以文字为主的个人博客,跑在一台共享云服务器上,前台是 Nuxt 3 SSR 渲染的杂志纸质·华丽风,后台是 Vue3 + Element Plus 的 SPA,后端是 Go 1.26 + Gin + GORM + SQLite 单文件库。从第一行设计文档到生产环境 HTTPS 可用,31 次提交、两轮安全审计战役、一次完整的数据库迁移和一次竞态检测工具链排雷——全程人机协作,AI 负责脚手架、样板代码和 API 细节,人负责架构判断、方案验收和体验决策。

一、缘起与全景
为什么要自己写博客?市面上成熟的方案很多,但我只想要一个"以文字为主、不开放注册、可邀请少量作者一起写"的站,而且想亲手控制每个像素和每条响应头。这个需求约束足够小、边界足够清晰,适合从零搭。
最终形态:三端分离——Go API、Nuxt SSR 前台、Vue SPA 后台——部署时由同一层 nginx 分发到同一路径,前端用相对 /api/v1 请求,零 CORS 配置。图片走 Gitee 私有仓库做图床,后端代理输出;数据库是 SQLite 单文件,落在 Docker 命名卷里,没有独立 DB 进程。整个栈无状态可重建、有状态只剩一个 snailblog.db 文件。
时间线上,git log 共 31 条提交,大段节奏:
| 阶段 | 提交范围 | 内容 |
|---|---|---|
| 骨架 | cd6a271 |
设计文档 + 项目初始化 |
| M1 后端 | 1fc0811 |
Go API 全功能 |
| M2 后台 | dedb058 |
Vue3 SPA |
| M3 前台 | 4b981fe |
Nuxt 3 SSR |
| M4 部署 | a25bd70 |
Docker + 测试 |
| 功能迭代 | ~8 条 | 文件导入、Typora 编辑器、主题 |
| 数据库迁移 | a6d5bc5→ae6952 |
MySQL → SQLite |
| 安全战役 | ~12 条 | 权限/令牌/限流/XSS |
| 部署上线 | 87f04f2→生产 |
端口收紧 + 宿主 nginx + HTTPS |
二、架构与选型
后端:Go + Gin + GORM + SQLite
选 Go 是因为编译产物单一、内存占用低、goroutine 模型天然适合 I/O 密集的 API 服务。Gin 只做路由和中间件串联——项目刻意不设 service 层,处理器直接用 GORM 操作数据库。这么做的前提是博客后端的业务逻辑足够薄(CRUD + 状态流转),多一层 service 只是把 db.Find() 换个马甲再调一遍;真正值得独立出来单测的是纯计算(图片压缩、令牌签发、限流器、slug 生成),这些全部收在 pkg/ 下独立包,无状态、无全局变量、接口注入。
数据模型一共 7 张表:
| 表 | 职责 |
|---|---|
users |
管理员 + 受邀作者;含token_version 列实现改密踢会话 |
posts |
文章;content_md 存 Markdown 原文;软删除;(status, published_at) 复合索引 |
categories |
全站共享分类;文章单选 |
tags |
全站共享标签;文章多选 |
post_tags |
多对多连接表 |
comments |
游客/作者评论;一级回复;ip_hash 仅频率限制不外泄 |
assets |
图床上传元信息记录(图片本体在 Gitee) |
数据库选了 SQLite,而且是纯 Go 驱动 github.com/glebarez/sqlite(fork 自 mattn/go-sqlite3,用 modernc 移植,免 cgo)。这意味着交叉编译 CGO_ENABLED=0 即可出静态二进制,Docker 镜像无需带 GCC 工具链。为什么不用 MySQL / Postgres?这台服务器上还跑着另一个项目,再吃一个外部数据库进程就是多余的资源与运维负担;SQLite 的单文件模式恰好契合"命名卷 + 启动即建表"的部署模型。
DSN 里显式开了四个 PRAGMA:foreign_keys=ON(GORM 外键约束生效)、journal_mode=WAL(并发读不阻塞写)、busy_timeout=5000(5 秒内自动重试 SQLITE_BUSY)、synchronous=NORMAL(WAL 模式下兼顾安全与速度)。GORM 配置 TranslateError:true,把唯一约束冲突翻译成 gorm.ErrDuplicatedKey,业务层用 errors.Is 一次判完。
一个从 MySQL 迁移到 SQLite 后暴露的坑:原本 utf8mb4_unicode_ci 带来的"用户名大小写不敏感"行为消失了——SQLite 默认 BINARY collation 把 Admin 和 admin 视为两个不同账号。解决方案是在 GORM tag 的 type 字段里写 TEXT COLLATE NOCASE(GORM 的列级 collate: 标签会被 SQLite 迁移器静默丢弃,实测生成的 DDL 里不带),并在 database.Open 启动时做正则校验——从 sqlite_master 查出建表原文,regex 确认 username 列声明了 COLLATE NOCASE;缺即 fail-fast 拒启,避免认证语义静默漂移。
浏览量计数在详情接口里用 UPDATE posts SET view_count = view_count + 1 WHERE id = ? 原子自增(不是先 SELECT 再 UPDATE 的 read-modify-write 竞态),写失败只记日志不阻塞阅读;不引入 Redis,量级不需要。
路由表与中间件链
main.go 的启动顺序:config.Load() → database.Open() → gin.New() → SetTrustedProxies() → 中间件链 → handlers.New() → Register() → http.Server 带优雅关停。
中间件顺序有意义:RequestID 必须在 Recovery 前面,这样 panic 日志里才带着追踪 id。CORS 在业务中间件之前。MaxBodyBytes 挂在 /api/v1 组级别,统一封顶 2MiB JSON 体;只有登记了两个 multipart 路由(routeUploadImage、routeImportFile)拿 heavyBodyLimit(取 max(10MiB, 配置导入上限) + 1MiB multipart 开销)。
路由分四组:
/api/v1
├── 公开读 (pub) : GET /posts, /posts/:slug, /tags, /archive, /img/*path
├── 认证 (auth) : POST /login, /refresh, /logout, /password; GET /me
├── 内容管理 (content) : authenticated + sessionFresh,文章/分类/标签/评论/上传/导入
└── 账号管理 (onlyAdmin) : authenticated + requireAdmin + sessionFresh
/feed.xml · /sitemap.xml · /healthz · /readyz (顶层)
一个关键的权限决策:分类和标签是全站共享词表、没有 author_id 可据以判归属——任何作者改/删都会波及他人已发布文章的归类。所以把"新建"开放给作者(给文章贴标签是发文常规动作),"改名/改 slug/删除"收归 admin。
sessionFresh 是本项目最核心的鉴权追加层:它在 Auth(验签)之后追加一次数据库回读,SELECT id, status, token_version FROM users WHERE id = ?,比对令牌里携带的 tv 与当前值。改密/重置/禁用都会递增版本或置零状态——于是旧 access 在下一次请求即失效,不用等它自然过期。代价是每个鉴权请求多一次主键查询;以本站体量完全可接受。
JWT 双令牌与令牌版本机制
Access 15min、Refresh 7 天,各自持独立签名密钥(HS256)。jwtx.Parse 的 keyfunc 里断言 *jwt.SigningMethodHMAC,算法钉死——不接受 alg:none、不接受 RS 系列,杜绝算法混淆攻击。
func Parse(secret []byte, tokenStr string) (*Claims, error) {
return jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
}
Claims 里除了标准的 exp/iat,还有 uid、role、tv(token_version)。签发的 access 和 refresh 用不同密钥——如果两密钥相同,7 天的 refresh 可直接当 access 用、绕过短 TTL 与用途隔离。config.go 在 prod 环境下检测两者相等则 log.Fatal 拒启。
Login 的处理顺序刻意设计为:限流 → 绑定 → 查库 → bcrypt 比对。先限流再 bcrypt,挡住爆破每次付 bcrypt CPU 的问题。账号不存在也走一次 verifyPassword——它内部用惰性生成的 dummyHash 空跑一次 bcrypt,消除 timing oracle(不存在的用户和错密码返回时间一致)。两者回同一句话:“用户名或密码错误”。
前台:Nuxt 3 SSR
真 SSR 意味着搜索引擎拿到完整 HTML、首屏无需 JS 即有内容。前台不装任何 CSS 框架——所有样式都是一套自建的 CSS 设计系统(色彩变量 + 响应式两档断点 + 篮球主题光标)。
Markdown 渲染用自定义的 markdown-it 实例,配置三件套:html:false(禁原文 HTML 注入)、linkify:true(自动把裸 URL 转链接)、typographer:true(智能引号)。链接安全由 markdown-it 内置的 normalizeLink 把关——它会把 javascript:/data: 协议清空。
渲染器同时被前台(v-html 注入正文)和后台的 SitePreview.vue 共用——后者通过 Vite alias @site-markdown 指向同一份 markdown.ts,保证编辑器预览和实际前台渲染逐字节一致。
后台:Vue 3 + Vite + Element Plus + Pinia
SPA,构建后是纯静态文件,由 nginx 伺服在 /admin/ 路径下。编辑器经历了两代:最初用 md-editor-v3(分栏实时预览),后来升级为 Typora 式的"所写即所得"——基于 Vditor,源码模式与富文本模式共享同一份 Markdown,切换时光标位置保持。
令牌存储经历了三步演进:
- 原状:access + refresh 双双存 localStorage → XSS 可窃长期 refresh
- 一期(0ba9c97):refresh 迁后端 httpOnly cookie,前端只留 access
- 二期(01ef1cf):access 也移出 localStorage →
http.js闭包变量;刷新页即丢;路由守卫ensureSession()靠 refresh cookie 静默续期 +/auth/me回读用户信息引导。
至此 XSS 脚本既偷不到 refresh(httpOnly cookie,JS 读不到)也偷不到 access(不在任何 Web Storage 里,在内存闭包中)——令牌窃取面基本闭合。代价:首屏多一次 refresh 往返、硬刷新会重跑一次引导。
图床:Gitee 私有仓库 + 后端代理
这是个"穷人 CDN"路线。上传接口 POST /api/v1/admin/upload 接收 multipart,原图上限 10MB;服务端 imgcompress.Compress 做四步压缩流水线(后面详述),最终产物 <=500KB 且边长 <=1920px;通过 Gitee OpenAPI 提交到私有 pic 仓库,路径 uploads/YYYY/MM/<随机slug>.<ext>。
私有仓库的 raw 直链对访客 403,因此前台引用的都是本站 /api/v1/img/<path> 代理链接。ProxyImage 处理器先查本地磁盘缓存(imgcache.Store,按路径 SHA256 落文件),命中直接返回;未命中则带 token 调 Gitee contents API 回源、写入缓存再返回。不存在的文件进 60 秒负缓存避免反复打穿。
token 只在服务端环境变量里存在一次,对外完全透明——响应里只有 Content-Type + 图片字节 + X-Content-Type-Options: nosniff。
三、功能演进
按里程碑四步走:
M1 Go 后端(约 55 个 Go 源文件):数据模型、JWT 签发/刷新、文章 CRUD + 草稿 + 软删、分类标签管理、游客评论(先审后发)、作者管理(邀请制)、图片上传与压缩、RSS/sitemap/健康检查、种子命令。main.go 启动时做配置校验(弱密钥检测、CORS 白名单、AutoMigrate 开关、NOCASE 自检、pandoc 可用性探测)。优雅关停:收到 SIGTERM 后 srv.Shutdown(ctx) 给在途请求 10 秒收尾。
M2 后台 SPA(约 15 个视图):登录、文章列表/编辑/预览、分类标签管理面板、评论审核队列、作者账号管理、图片上传历史。Pinia store 管 auth 状态与草稿缓存。本地草稿键按登录用户 id 隔离(snailblog:draft:<uid>:<postID|new>),防止同浏览器跨账号串稿。
M3 Nuxt 前台:首页(精选大版式 + 描金装饰 + 文章列表)、文章详情(粘性目录 + 正文字号下沉 + 评论树)、归档/分类/标签页、RSS 发现链接。响应式两档断点:<=1024px 平板、<=640px 手机。
M4 Docker 部署:docker-compose.yml 编排三服务(api / web / proxy)。Dockerfile.api 多阶段构建(golang:1.26-alpine → scratch,CGO_ENABLED=0 出静态二进制 + seed 命令);Dockerfile.web 构建 Nuxt .output 并只跑 node server.mjs;Dockerfile.proxy 在同一镜像内构建 web-admin SPA 并配好 nginx 路由。SQLite 落在命名卷 db_data,图片缓存落在 img_cache。
后续功能迭代:
- 文件导入(
POST /api/v1/admin/import):Markdown / .docx via pandoc / .zip 含多图 + md,解析产物只回给编辑器、不写库 - Typora 式编辑器(Vditor 替换 md-editor-v3)
- 暗色/亮色主题切换
- MySQL → SQLite 迁移(
cmd/migrate一次性搬家工具,完成后已删除)
四、安全审计战役
这是整个项目中投入最大、收获最丰的一段。不是一次性扫描——是两轮完整的攻击者视角穷尽排查。
方法论
原则:诚实优先——每个"漏洞"假设都用源码证伪或坐实,不制造不存在的问题。所有修复只加闸/收口,绝不以删代码为手段——这是为了避免修复引入功能回归。
实操:独立测试库 + 种子账号搭一台可攻击的靶机(全在 .gitignore 覆盖的 temp/secaudit/ 下)。黑盒用裸 HTTP/1.1 Python 客户端打真实进程(读 PeakWorkingSet 观测内存变化),白盒逐路由/中间件/包通读。每个漏洞都给"补丁前能打成什么样 / 补丁后实测关闸"的成对证据。
一个关键的实验设计教训:限流器是进程内固定窗口计数器(重启即归零),跨阶段共享同一进程会让后面的用例撞上前面吃光的配额(既可能假失败也可能假成功)。故回归驱动器 regress.py 在每个阶段前重启靶机(复用同一 audit.db,只归零内存计数器),读数才可比。
第一轮战役:权限、令牌与防枚举
第一轮聚焦于认证/授权/令牌流。主要成果:
- 令牌用途隔离:access 和 refresh 各有独立密钥;
config.go的weakJWTSecrets检测——prod 下任一密钥停留在仓库公开占位值即log.Fatal拒启;两密钥相等也拒启。JSON 请求体统一封顶 2MiB,标签数设上限防 SQLite 变量溢出。 - 改密踢会话:
token_version递增 +sessionFresh中间件每个鉴权请求回读比对,实测旧 access 在改密后下一次请求即被 401。 - 登录防枚举:
dummyHash是sync.OnceValue惰性生成的真实 bcrypt 摘要;用户不存在时verifyPassword拿它空跑一次 bcrypt(耗时等价于真实比对),消除 timing oracle。 - 评论审核归属:
canModerateComment先SELECT comment→post.author_id再canEdit——杜绝跨作者越权审核。 - refresh 迁 httpOnly cookie:
SameSite=Strict+Path=/api/v1/auth限制作用域。Secure标志的取值逻辑值得注意:不是按AppEnv==prod判断,而是看SITE_URL是否https://前缀——避免纯 http 生产环境因 Secure 而拒收 cookie 以致登录/刷新坏掉。 - access 迁内存:
http.js里 access token 存在闭包变量(非 Web Storage、非 cookie),每次performRefresh成功后更新;ensureSession()在路由守卫里做首屏静默续期。
第二轮战役:复核推翻"无新增漏洞"
第一轮结束后曾得出"本轮无新增可利用漏洞"的结论。第二轮以攻击者视角重开,该结论被推翻——在 XSS / 限流 / 图片压缩 / 导入 / schema 五个问题域坐实了 8 项可被外部客户端利用的真实缺陷(编号 V1–V7、V9)。
V1 存储型 XSS — 围栏 info 串
全前台仅一处 v-html(文章正文),数据源是 web-front/utils/markdown.ts 的 markdown-it 渲染器。html:false 本已阻止原文 HTML 注入,但自定义 highlight 回调返回 <pre><code...> 开头的字符串。markdown-it 内置 fence 规则里的 renderAttrs 转义只在我们返回的 HTML 不以 <pre 开头时才走到——恰好被我们的返回值短路了,转义责任全落回自己手上。
而围栏信息串(``` 之后那一截 lang)是作者/导入器可控的外部输入。旧实现:
// 旧代码(有漏洞)
highlight(str: string, lang: string) {
const cls = lang ? ` class="language-${lang}"` : '' // 直接拼接!
return `<pre><code${cls}>${md.utils.escapeHtml(str)}</code></pre>`
}
一个裸引号 x"onclick=document.title="XSS-1"// 即可提前闭合 class 属性、注入事件处理器;更隐蔽的是 " 会被 markdown-it 的 unescapeAll(token.info) 解回 " 再进属性——实体编码绕过了"看起来安全"的直觉。生产前后台同源部署,一处存储型 XSS 即可读到共享 localStorage 里的 admin token。
修复只需一行:
// 修复后
const cls = lang ? ` class="language-${md.utils.escapeHtml(lang)}"` : ''
验证方式不是打实时 SSR(渲染器是唯一渲染路径,SSR 取产物 v-html 原样注入不二次转义),而是跑 esbuild 打包的真实 renderMarkdown 输出 + 浏览器语义 HTMLParser 判定属性是否可执行。6/6 测试用例通过,所有恶意 lang 的内容都留在单个 class 属性值内。
V2 请求体上限按 Content-Type 豁免可绕
旧 MaxBodyBytes 中间件对 Content-Type 前缀 multipart/ 整条豁免 2MiB 限制。Content-Type 是客户端随手可写的头——写个 multipart/fake 就令豁免生效,而下游 ShouldBindJSON 压根不看 Content-Type 照样解析。
补丁前实测:8 路并发 32MB multipart/fake 载荷,1.3 秒内把进程峰值工作集推高 512MB(内存交给外部支配)。
修复:改为按 c.FullPath()(服务端路由表决定的字符串字面值)选额度——只有 routeUploadImage 和 routeImportFile 两个口拿 heavyBodyLimit(有限大额度,非无上限)。FullPath 为空(404/将来忘登记的新上传口)时兜底旧的前缀判断(功能事故优于安全收口)。
补丁后实测:六种恶意 Content-Type(含 multipart/fake、裸 multipart、大小写 Multipart/)全部 400;8x32MB 并发 peak 仅 +16.9MB(旧版 512MB)。
V3 评论频率闸在绑定之后
旧 CreateComment 的顺序:ShouldBindJSON → 限流。只要载荷让绑定失败(缺字段/类型不符/被体积闸掉),函数在限流之前就 return——频率闸对这类请求永不生效,成为"免费"的校验器探测口。
补丁前实测:同 IP 1.3 秒内 8 路并发超大体全部抵达字段校验、无一被 429 拦下。
修复:把 hashIP + rateLimited 整块前移到 ShouldBindJSON 之前。仅调顺序、不删逻辑。补丁后同 IP 并发 8 发小评论 → {200:3, 429:5},闸在绑定之前生效。
V4 自助改密口无限流
改密与登录一样每次失败都付一次故意调贵的 bcrypt(bcrypt.CompareHashAndPassword),但旧版只有 Login 挂了 loginLimit。拿到一枚 access(或已在会话里的作者)就能既烧 CPU、又对原密码无限试探。
修复:新增 pwLimit = ratelimit.New(5, 5*time.Minute),key 为 "pw:<uid>:<IP>"——改密是极低频动作,5/5min 宽到误伤不了真人。实测:同令牌 12 次 → {401:5, 429:7}(旧版全 401 无 429)。
V5 图片代理匿名枚举
图床仓库私有,未命中缓存的回源是一次带 token 的对外 HTTPS。uploads/xxx.png 路径可由匿名客户端任意枚举——不存在的只进 60s 负缓存,换个随机名字就是新一次外呼。不限流则单客户端可把本站当打 Gitee API 的放大器,很快踩穿图床侧频率上限(真图也开始 404/502)。
修复:新增 imgLimit = ratelimit.New(240, time.Minute),且只对 !h.Img.Has(path) && !Allow(key) 才扣额——命中磁盘缓存的不扣,避免误伤 NAT 后的正常访客看不到图。实测 260 发互不相同的路径 → {502:240, 429:20},首次 429 精确落在第 241 发。
V6 图片压缩并发槽数无封顶
maxConcurrentCompress = 2×GOMAXPROCS 无上限。实测一张刚好卡在 MaxPixels(50MP) 以内的 7000x7000 PNG(文件仅 203KB)解码后是 187MB 像素缓冲,加上缩放与逐级降质中间产物一次推高 256MB。20 核机器 = 40 槽 x 256MB ≈ 10GB 交给并发上传者支配。
修复:绝对封顶 maxCompressSlots = 8,maxConcurrentCompress = min(2*GOMAXPROCS, 8)。10 秒排不到槽位回可重试的 503(ErrBusy),只是变慢、不会 OOM。
验证中有一个重要教训:黑盒 PeakWorkingSet 测到 12 路并发 +2821MB 乍看像闸没生效——实际 Go 运行时不立即归还已释放内存给 OS,分批分配叠加在历史峰值之上。PeakWorkingSet 是单调读数,不能证明并发度。真正判定得用白盒闸内计数:12 路抢闸,测得闸内峰值 = 8、4 路排不到回 ErrBusy。
V7 导入 Markdown 正文无专项体积闸
zip 解压总预算 100MiB 对图片条目合理(需原字节传图床),但对会被整个读成字符串、原封不动回显给客户端的 Markdown 太宽。一个 116KB 的 zip(内含一篇 46.7MB 的 md)→ 48.3MB 响应 + 服务端峰值工作集 +258.8MB(上传→响应 ≈426x,上传→内存 ≈2283x)。
修复:maxContentMDBytes = 2MiB。这个数字不是拍下来的,是从下游反推的——导入产物要填回编辑器、再由 POST /admin/posts 存库,那个口对整个 JSON 体封顶 2MiB;比它大的正文导进来也存不回去。
闸的层次:zip 条目读前先看 UncompressedSize64 声明(第一道预筛)→ readBounded 用 io.LimitReader 封顶实际读出字节(第二道真闸)→ Parse 返回前兜最终产物 len(第三道网,覆盖 md 直传和 docx 经 pandoc 膨胀的路径)。三处都是只加闸不删码。
V9 NOCASE 自检跟随 AutoMigrate 开关被跳过
checkUsernameNocase 是只读护栏(查 sqlite_master 确认 username 列有 COLLATE NOCASE),防的是认证语义静默漂移。但它嵌在 if cfg.DBAutoMigrate {} 分支内——而生产部署惯例恰是"关自动迁移、拿存量库继续跑"。护栏只装在了不需要它的那条路上,真正需要它的生产路径反而裸奔。
修复:把 checkUsernameNocase(db) 移出 AutoMigrate 分支,Open 内无条件执行。它只读不写、不看开关,缺 collation 即带修复指引 fail-fast 拒启。
被证伪的假警报
诚实审计不只是找漏洞,还包括不制造不存在的漏洞:
- XFF 伪造限流键:假设 nginx
$proxy_add_x_forwarded_for追加 + gin 取最左 → 客户端注入伪造 IP 绕过限流。读 gin v1.12.0 源码Engine.validateHeader证实:XFF 按,切后从右往左遍历,返回第一个非受信 IP(仅 i==0 兜底最左);边缘代理每跳把真实客户端 IP 追加在注入值右边 → 注入的最左伪造值永远够不到。未据该假设改任何代码。 - JSON-LD script 逃逸:
useHead往<script type=ld+json>注innerHTML=JSON.stringify(title/summary),曾疑</script>可逃逸出去开新标签。读 unhead SSR 的tagToString确认对非 title 标签把</tag改写为<\/tag→ 无法逃逸;客户端该值是 script 的 text 节点不重解析。非漏洞,未改。
五、测试与排雷
后端测试架构
项目没有 service 层——处理器直接用 GORM,所以测试分三类:
纯函数单测(pkg/ 各包):
imgcompress:压缩流水线全路径(透传/缩放/降质/拒绝);二分选档与线性扫描的等价性;EXIF 方向转正;格式闸门(超像素/gif/webp 分流);并发闸(TestCompressGateCapsConcurrencyAndQueues:12 路抢闸测得闸内峰值=8、4 路排不到)。另有 benchmark 自检 fixture 前提(挑错图直接 FAIL)。jwtx:签发/解析/过期/算法钉死/密钥隔离。ratelimit:固定窗口计数、窗口到期重置、evictLocked满额淘汰。now func() time.Time字段支持注入假时钟。config:弱密钥检测、两密钥相等拒启、prod 缺必要变量拒启。slugx:中文/特殊字符 → URL-safe slug。gitee:OpenAPI 提交的 mock 测试。
真库集成测试(database/):跑在 TempDir 里的 SQLite 文件上——验证 username 的 NOCASE 唯一性(Admin 撞 admin)、WAL + busy_timeout 下的并发写不报 SQLITE_BUSY。
HTTP 冒烟测试(handlers/):httptest 只覆盖不需要数据库连接的部分——健康检查、受保护路由拒匿名、无效令牌 401、author 令牌在 admin-only 路由 403、未知路由 404、登录限流。另有归档月份边界(真实 SQLite)用例。
限流器实现细节
ratelimit.Limiter 是并发安全的固定窗口计数器。Allow(key string) bool 的逻辑:
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
e, ok := l.hits[key]
if !ok {
if len(l.hits) >= l.maxKeys { l.evictLocked(now) }
e = &entry{resetsAt: now.Add(l.window)}
l.hits[key] = e
}
if now.After(e.resetsAt) {
e.count = 0; e.resetsAt = now.Add(l.window)
}
e.count++
return e.count <= l.limit
}
窗口到期就地重置,不做后台清理——靠访问惰性回收。maxKeys=65536 是防内存增长的兜底:满了先扫一遍丢已过期的;仍满则按最早到期排序、淘汰前 20%。排序用"快照进切片再 slices.SortFunc"而非"比较函数里查 map"——实测从 57.7ms 降到 20ms。
它只解决单实例场景:状态在内存里,重启即清零,多实例各算各的。本项目是单容器部署,够用。
Windows 竞态检测排雷
本机默认 CGO 编译器是 msvcrt 版旧 MinGW(GCC 8.1.0),Go 1.26 的 race runtime 按 UCRT 构建——二者入口点不匹配,go test -race 全部以 0xc0000139 (STATUS_ENTRYPOINT_NOT_FOUND) 崩溃,进程加载即挂、测试根本没跑。
排查过程走了弯路:初始假设是 PATH 中多套 MinGW DLL 冲突(系统里同时有 Anaconda 的、OpenCV 的、独立安装的),剔除后仍复现,排除该假设。最终用 CLion 自带 GCC 13.1.0(UCRT 版)验证可正常编译并运行 race 二进制——确认根因是 race runtime 依赖 api-ms-win-crt-*.dll(UCRT 独有),旧 msvcrt 不提供。
解法:改用 WinLibs POSIX/UCRT GCC 16.1.0。但还有一个 shell 层的坑——harness 的非交互 cmd 中 set "PATH=..." && go test 不生效:%VAR% 在同一条 && 链式命令里是解析期展开(set 还没执行时 %PATH% 就已经被旧值替换了),值设不上。必须写成 .cmd 批处理文件分步执行:
@echo off
set "PATH=C:\...\ucrt-gcc\bin;%PATH%"
set "CC=gcc"
go test -race -cover -count=1 ./...
最终结果:go test -race -cover -count=1 ./... 逐包 ok、无任何 WARNING: DATA RACE。覆盖率:imgcompress 93.7%(race 下耗时 65s)、handlers 76.2%、ratelimit 90.9%。
测试文件不入仓库
.gitignore 里有 *_test.go 规则——测试代码在本地跑,不推到共享仓库。原因:这是一个人机协作项目,AI 生成的测试文件数量可能随会话膨胀;保持仓库干净、只收功能代码和配置,测试由开发者本地按需执行。回归证据(r1_authz.txt 等)也落在 temp/ 里,不入版本库。
六、生产部署
部署架构:三服务(api / web / proxy)在 Docker Compose 里编排,容器 nginx 只绑 127.0.0.1 发布 8081(前台) / 8082(后台调试) / 8083(API 直连调试);公网入口收敛到宿主 nginx 的 listen 81(与同机的简历站错开),再由宿主 nginx + certbot 终结 HTTPS。
docker-compose.yml 的要点:
api服务:环境变量从.env透传(JWT 密钥、Gitee token、SITE_URL 等);DB_AUTO_MIGRATE默认 true(空库首启建表),schema 稳定后置 false。web服务:Nuxt runtimeConfig 的运行时覆盖只认NUXT_前缀(apiBase→NUXT_API_BASE),nuxt.config.ts里process.env.API_BASE是 build 时求值——这个坑导致过 SSR 首页拿不到文章的 bug(14f5b2a 修复)。SSR 在容器内必须走 docker 网络名http://api:8080/api/v1,不能写 localhost。proxy服务:Dockerfile.proxy在同一镜像内构建 web-admin SPA(npm run build产出 dist/)然后 COPY 到 nginx 镜像。三个入口端口共用同一套location规则:/→ web、/admin/→ 静态、/api/→ api、=/feed.xml、=/sitemap.xml、=/healthz→ api。client_max_body_size 22m(略大于文件导入默认上限 20MB)。
更新部署:侦察优先于假设
现网已有 db_data / img_cache 命名卷且含真实文章。部署铁律:绝不 docker compose down -v(-v 会删除命名卷,等于删库)。
完整流程(每步破坏性操作前必备份):
- 无损侦察:读取远端 docker-compose / nginx conf / .env,只读探测(
cat /etc/os-release、docker ps、docker volume ls、nginx -T),确认是"带数据的旧版部署"而非全新。 - 备份:对
db_data、img_cache卷、.env、nginx 配置做 tar/cp 至/home/ubuntu/deploy-backups/<timestamp>,记录 SHA256。 - 预检排雷:
git fetch确认 fast-forward 无分叉;检查users.username列是否已有COLLATE NOCASE(从sqlite_master查 DDL 原文确认,防 V9 自检拒启);验证 api 基线正常。 - 代码更新:
git pull拉取最新安全修复;修改SITE_URL/CORS_ORIGINS为 https 域名;docker compose up -d --build(不重建卷)。 - 域名接入:宿主机 nginx 新增
blog-domain.conf(反代 127.0.0.1:8081),nginx -t通过后nginx -s reload。 - HTTPS 签发:
certbot --nginx -d <domain>(HTTP-01 challenge),自动配置 443 + 80→443 跳转,系统 timer 自动续期。 - 公网验证:从本机 curl 走真实 DNS/TLS 路径,验证首页/文章/feed/sitemap/健康检查;确认 CSP、X-Frame-Options、X-Content-Type-Options、Referrer-Policy 全套安全响应头逐字下发(
always参数确保 502 也带头);简历站不受影响。
安全响应头
nginx 唯一入口统一 add_header ... always:nosniff、Referrer-Policy: strict-origin-when-cross-origin、X-Frame-Options: DENY、Permissions-Policy: camera=(), microphone=(), geolocation=(),及一条 CSP:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' https: data:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://fonts.gstatic.com;
frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none';
'unsafe-inline' 是妥协——Nuxt SSR 内联水合脚本和编辑器的内联样式都需要它。没有 nonce 下不挡全部 XSS,但显著收窄外联/点劫持/资源注入面。已验证 Vditor 资源全自托管、lute.min.js 非 WASM(不需 wasm-unsafe-eval)。
国内服务器三连镜像
Docker build 在国内服务器会遇到三层网络问题:Alpine 包管理慢/超时、npm registry 不通、Go module proxy 被墙。最终在 Dockerfile 里统一配了镜像源:apk add --repository 指向国内镜像(Dockerfile.proxy 里装 pandoc 也需要)、npm config set registry、GOPROXY=https://goproxy.cn,direct。
表结构变更流程
项目没有独立迁移工具(cmd/migrate 是 MySQL→SQLite 一次性搬家用的,已完成使命删掉了)。沿用 GORM AutoMigrate——它只做"加表/加列/加索引"的非破坏性变更(不删列、不改类型)。在 DB_AUTO_MIGRATE=false 的存量库上要落一次结构变更:临时把该 env 置 true 重启 api 一次(AutoMigrate 补上缺失的列,新列按模型 default 回填、不动既有数据),确认无误后置回 false;或直接对库执行等价的 ALTER TABLE。
当前实例:引入 users.token_version(改密即失效旧会话)后,存量生产库须按此流程补列——否则 sessionFresh 每次鉴权请求回读 token_version 会因缺列而 500。
七、图片压缩流水线详解
这部分独立成章,因为它是一个设计密度很高的 pkg,值得完整拆解。
四步闸门
上传 10MB → [1.头部闸] → [2.格式分流] → [3.解码+缩放] → [4.质量二分] → <=500KB
第一步:只读文件头部拿格式与尺寸。image.DecodeConfig(bytes.NewReader(data)) 不分配像素缓冲,只解析 header。Width*Height > MaxPixels(50MP) 直接拒 ErrTooLarge——不给解码器分配几十 GB 内存的机会。非法尺寸(0 或负)也拒。
第二步:格式分流。GIF/WEBP 在 DecodeConfig 后立刻决定命运——达标透传、超预算回 ErrNoLosslessPath(告诉上传者"请先压小或改传 JPG/PNG")。它们绝不能进入 image.Decode:标准库 GIF 解码只剩第一帧、x/image/webp 没有编码器。JPEG/PNG 若已在 1920x1920 方框内且不超 500KB 也原样返回——重编码只会让 PNG 变大,还会抖掉 EXIF。
第三步:真处理。先排队拿压缩槽位(channel 信号量,10s 排不到回 ErrBusy → 503)→ imaging.Decode(data, AutoOrientation(true)) 解码时按 EXIF 方向物理转正 → imaging.Resize(img, w, h, Lanczos) 等比收进方框 → 有任何非不透明像素则保持 PNG 无损输出(压不到预算即拒 ErrTooComplex)。
第四步:JPEG 质量二分。在 [MinQuality=62, StartQuality=85] 区间里找"输出不超 500KB 的最高质量"。先单试一次 85——多数照片在这一档一次命中,省掉整轮二分;不达标再进标准二分。一个 bytes.Buffer 从头用到尾(Reset 不重新分配),档位之间 snapshot(buf) 把达标产物搬进独立切片(因为 buf 还要复用)。
性能优化细节
jpegSource 函数把 *image.NRGBA(imaging.Resize 的产物类型)零拷贝 reinterpret 为 *image.RGBA——因为编码器只对 RGBA/YCbCr/Gray 有专属快路径,其余每像素一次 At() 接口调用 → color.NRGBA 装箱堆分配。profile 里这是全函数最大的一笔(占分配字节的 38%、分配次数的 99%);jpegSource 把它从 491 万次降到 136 万次。
sync.Pool 缓冲池把 bytes.Buffer 从"每张图十几次翻倍重分配"摊平成"每次 GC 之后第一次"。但实测这只值 1-2MB 一档——真正的分配优化大头是 jpegSource。
并发闸实现
用带缓冲 channel 当信号量(比手动维护计数器 + mutex 更 Go 风格):
var compressSem = make(chan struct{}, max(1, maxConcurrentCompress))
func acquireCompress() bool {
timer := time.NewTimer(compressWait)
defer timer.Stop()
select {
case compressSem <- struct{}{}: return true
case <-timer.C: return false
}
}
八、导入解析器详解
另一个设计密度很高的 pkg。
三种入口
| 扩展名 | 路径 | 图片处理 |
|---|---|---|
.md / .markdown |
直接string(data) → 提取标题 |
无本地图片 |
.zip |
archive/zip → 定位唯一 .md → 相对图片经 store 落图床 → 重写链接 |
核心场景 |
.docx |
写临时文件 → spawn pandoc → 读out.md + media/ → 图片经 store 落图床 |
依赖外部二进制 |
Parse 是统一入口,先按 MaxImportBytes 做体积兜底 → dispatchParse 按扩展名分派 → 最终产物再过 maxContentMDBytes(2MiB) 的闸。
zip 三重防御
- 条目数封顶:
len(zr.File) > maxZipEntries(500)直接拒。 - 声明未压缩总量预筛:各条目的
UncompressedSize64无符号累加、相加前判越界(addWithinBudget)。这只为省掉无谓读取,不是唯一防线。 - 实际读出字节封顶:
readBounded用共享递减的io.LimitReader(rc, actualBudget+1)对真正展开的字节数设上限——这才是真闸。多读 1 字节用于判定越界。
addWithinBudget 的无符号设计:旧实现按 int64 累加再与预算比大小——单条只要谎报 >= 2^63,转 int64 就变负,反而把总量拉回预算内绕过闸。全程无符号则"越界即拒",不存在变号。
zip 图片接受集(imageExts)刻意与 imgcompress 解码注册路径和 imgcache.allowedExts 代理输出集三方对齐(只含 png/jpg/jpeg/gif/webp)。svg 可内嵌脚本构成同源 XSS、bmp 不被任何环节支持——放进来只会让正常导出整篇崩 500。
docx 安全设计
PandocAvailable启动时探测一次;false 时直接返回可识别错误,不 spawn 进程。- 参数写死
"input.docx"(用户文件名只用于扩展名分派,绝不进 argv)。 cmd.Dir = tmpDir+ 30sCommandContext超时 +defer RemoveAll。- 媒体解析
HasPrefix(fp, tmpDir+sep)拦../逃逸。
残留注记:docx 本身是 zip,pandoc --extract-media 落盘不受 readBounded 管;auth 门内 + 超时 RemoveAll 已缓解,彻底治需临时目录配额(属部署层)。
链接重写
rewriteImages 用 Go 的 RE2 正则(无回溯引擎、不存在 ReDoS)匹配  和 <img src="..."> 中的相对路径引用,对每个引用调 resolve 回调。resolve 的去重逻辑:同一图片条目被多处引用只落一次,uploaded map[string]string 复用已返回的链接。找不到对应文件的引用记入 Unresolved(软警告不阻断);上传失败是硬错误(整次导入作废,不返回半成品)。
九、人机协作复盘与数字收官
人和 AI 怎么分工
回顾整个过程,分工的边界很清晰:
AI 擅长的部分:
- 脚手架和样板代码:Gin 路由表、GORM 模型定义、Vue/Nuxt 页面骨架、Dockerfile/compose 编排——这些重复性高、网上有海量先例的劳动,AI 秒出初稿。
- API 细节与参数查询:bcrypt cost 取值、markdown-it 的 fence 规则如何绕过内置转义、unhead 的
tagToString对</script的转义行为、ginvalidateHeader的从右往左遍历逻辑——AI 读源码比人快。 - 测试代码与诊断脚本生成:
httptest冒烟用例、裸 HTTP/1.1 靶击脚本、esbuild 打包真实渲染器 + HTMLParser 判定——机械但量大。 - 安全漏洞扫描时的模式匹配:遍历所有路由找"限流在绑定之后"这类结构反模式,AI 不会遗漏。
- 重构与一致性检查:把 7 个处理器的错误响应统一成
response.OK/BadRequest/Unauthorized风格;把限流器从包级变量改为实例字段(避免测试间配额互污)。
人不可替代的部分:
- 架构量级判断:“要不要 service 层”→ 不要,这个项目规模不需要多一层间接;“数据库要不要独立进程”→ 不要,单文件 SQLite 够用;“令牌存 localStorage 还是 cookie 还是内存”→ 逐步升级到内存,代价与收益的取舍是人的决策。
- 方案验收:AI 给一版修复代码,人读逻辑确认它确实只"加了一道闸"而非改了语义;人跑测试、在浏览器里验收真实体验。
- 体验决策:篮球主题光标的图形设计、暗色/亮色切换动画的节奏、Typora 式编辑器的光标保持逻辑、"导入失败时该告诉用户什么"的文案——这些"用着对不对"的判断 AI 无法替代。
- 安全审计中的定性:“这是不是真漏洞”、“改了会不会引入功能回归”、“这个假设该不该拿源码证伪”——保守与激进的平衡感。
- 运维决策:"保留数据卷"的优先级高于一切;"每一步破坏性操作前必备份"的纪律。
踩过的坑里最贵的三条
- GORM 的
collate:tag 在 SQLite 迁移器中被静默丢弃。表面上加了 tag 就完事,实际生成的 DDL 里列和索引都不带 collation。必须写进type:TEXT COLLATE NOCASE才落到列上。教训:永远验证落地的 schema,而不是信任 ORM 的声明。 - PeakWorkingSet 不能证明并发度。黑盒安全测试里观测到 +2821MB,第一反应是"并发闸失效了"——实际 Go 运行时不立即归还已释放内存给 OS。真正判定并发度得用白盒闸内计数。教训:观测指标要理解其物理含义,单调递增的量不能当瞬时值用。
- 限流闸的相对位置决定它是否有意义。把频率检查放在
ShouldBindJSON之后,等于对畸形/超大体完全失效。教训:流程编排里对资源消耗的因果理解比代码本身更重要。
硬数据
| 维度 | 数值 |
|---|---|
| 总提交数 | 31 |
| Go 后端源文件(不含测试) | ~55 |
| 数据表 | 7 |
| API 端点 | ~30 |
| 前端页面/视图 | 前台 9 + 后台 11 |
| 安全审计发现真实缺陷 | 8(V1–V7、V9) |
| 安全审计证伪假警报 | 2(XFF、JSON-LD) |
| 所有安全补丁的统一特征 | 只加闸/收口,零删代码 |
| 回归测试项 | 62 项 0 失败 |
| imgcompress 覆盖率(race) | 93.7% |
| handlers 覆盖率(race) | 76.2% |
| ratelimit 覆盖率(race) | 90.9% |
| Docker 服务数 | 3(api / web / proxy) |
| 外部依赖服务 | 0(无独立 DB / 无 Redis / 无 CDN) |
| 图片压缩质量二分区间 | 62–85(24 档,最坏编 6 次) |
| zip 解压总预算 | 100MiB |
| 单次导入体积上限 | 20MB(可配置) |
| 导入正文上限(反推自下游) | 2MiB |
| 压缩并发槽绝对上限 | 8 |
| 登录限流 | 10 次/5min/IP |
| 改密限流 | 5 次/5min/用户+IP |
| 图片回源限流 | 240 次/min/IP |
后续可做的事
诚实标注当前状态中的未闭合项:
- 前端无自动化测试(只有
dev/build/preview三个 script),关键流程靠人工验收;移动视口下横向滚动与触摸目标尺寸仍有遗留项。 - CSP 的
script-src因 Nuxt SSR 内联水合与编辑器内联样式仍需'unsafe-inline';上 nonce 是二期可选方向。 - 真浏览器对完整 docker 栈的 CSP 违规扫描尚未执行(dev 走 vite 不过 nginx)。
- 图片代理的磁盘缓存无淘汰策略(图名含随机 slug 且写入后不变,理论上不会发生同名不同内容,但无界增长需要未来关注)。
- 文章 CRUD / 评论 / 上传的完整业务路径仍只作为人工验收项(handler 层的 httptest 需要数据库连接,当前只测了无 DB 的鉴权边界)。
SnailBlog 不伟大,它只是一个人 + AI 用 31 次提交把一台空服务器上跑的博客做到了:能写、能看、能搜、能防住匿名攻击者的 8 种真实打法和 2 种假警报。它让我完整走过了架构选型、功能实现、安全加固、测试排雷和生产部署的全周期——每一步的决策和代价都记录在案。这就是这个项目最大的价值:它是一份可信的、从 0 到上线的、人机协作实践样本。
评论 (0)