我希望这个博客不只是公开文章站,也能承担一部分个人内容展示能力:有些图片、PDF、笔记或者作品集不应该出现在首页、菜单、归档、RSS 里,但我可以把一个指定 URL 发给别人,对方只有带上密码参数才能查看。
纯静态博客本身不适合做这件事。原因很直接:如果文件已经被 Hugo 构建进 public/ 或 static/,那前端页面上的密码判断只是遮挡,知道真实路径的人仍然可以绕过。因此更合理的做法是保留 Hugo 作为公开站点,再增加一个服务端私密内容网关。
目标
这次改造的目标是:
- URL 通过 query 参数里的密码校验,例如
/p/trip-2026-cover?password=xxx。 - 密码配置写在本地文件里,不依赖 Vercel Storage、KV、数据库或 Dashboard 环境变量。
- 支持 PNG、JPG、WebP、GIF、AVIF、PDF、Markdown、HTML、TXT 等常见内容。
- 私密内容不进入博客首页、菜单、归档、RSS 或 sitemap,只能通过指定 URL 访问。
- 每个内容支持多个密码,便于临时分享和撤销。
- 支持
download=1,在浏览器预览和下载之间切换。 - 支持相册模式,一个密码打开一组图片和 PDF。
- 支持
expires + sig过期签名链接。 - 支持无密码时的访问提示页,输入后跳转到带参数 URL。
- 所有私密响应都带
X-Robots-Tag: noindex, nofollow和Cache-Control: private, no-store。 - 支持批量导入脚本,自动生成别名、密码哈希和配置模板。
- 在本地存在 ImageMagick 时,批量导入脚本可以生成缩略图、水印图和去 EXIF 版本。
- 支持 alias,不暴露真实文件名,只暴露
/p/trip-2026-cover这样的入口。
架构选择
最终方案是新增一个 Node.js Vercel Function:
/p/:id -> /api/private-content?path=:id
旧的图片入口也保留兼容:
/i/:id -> /api/private-content?path=:id
真正的私密文件不放在 public/、static/ 或公开文章目录,而是放在类似下面的位置:
private-content/
private-images/
访问时,函数按 alias 从本地配置文件中找到真实文件路径,先校验密码,再决定返回原始文件、Markdown 预览页、相册页或者下载响应。
配置格式
新的配置文件优先使用:
private-content.json
private-content.local.json
旧的 image-access.json 仍然兼容。一个典型图片配置如下:
{
"trip-2026-cover": {
"file": "private-content/photos/2026/real-camera-file-name.jpg",
"passwordSha256": "SHA256_OF_PRIMARY_PASSWORD",
"passwordSha256List": [
"SHA256_OF_TEMPORARY_PASSWORD"
],
"title": "Trip cover",
"thumbnail": "private-content/photos/2026/thumbs/real-camera-file-name.jpg",
"watermarkedFile": "private-content/photos/2026/watermarked/real-camera-file-name.jpg",
"watermarkText": "colommar.asia"
}
}
这里 trip-2026-cover 是公开 URL 里的 alias,真实文件名不会暴露在 URL 中。
访问方式:
/p/trip-2026-cover?password=YOUR_PASSWORD
下载方式:
/p/trip-2026-cover?password=YOUR_PASSWORD&download=1
PDF 和 Markdown
PDF 会按 application/pdf 返回,并支持 Range 请求,这样浏览器内置 PDF 阅读器可以按需加载。
Markdown 默认会被渲染成一个私密 HTML 页面:
{
"private-note": {
"file": "private-content/notes/private-note.md",
"passwordSha256": "SHA256_OF_PASSWORD",
"title": "Private note"
}
}
如果加上 download=1,则返回 Markdown 原始文件下载。
相册模式
相册用 files 数组表示:
{
"portfolio-album": {
"title": "Private portfolio album",
"passwordSha256": "SHA256_OF_ALBUM_PASSWORD",
"watermarkText": "colommar.asia",
"files": [
{
"file": "private-content/portfolio/photo-1.jpg",
"title": "Photo 1",
"thumbnail": "private-content/portfolio/thumbs/photo-1.jpg"
},
{
"file": "private-content/portfolio/case-study.pdf",
"title": "Case study PDF"
}
]
}
}
访问:
/p/portfolio-album?password=YOUR_PASSWORD
相册页不会出现在公开博客 UI 里。它只是一个受密码保护的独立页面,页面内部的图片和 PDF 链接也会继续走同一个网关。
过期签名链接
如果某个内容需要临时链接,可以配置:
{
"resume-pdf": {
"file": "private-content/docs/resume.pdf",
"passwordSha256": "SHA256_OF_PASSWORD",
"requireSignedUrls": true,
"signingSecret": "CHANGE_THIS_LOCAL_SIGNING_SECRET"
}
}
签名计算规则是:
HMAC_SHA256(signingSecret, contentId + "\n" + expires)
生成链接可以用脚本:
node scripts/sign-private-url.mjs resume-pdf --password "your-password" --expires 2h --base https://www.colommar.asia
输出形式类似:
https://www.colommar.asia/p/resume-pdf?expires=1798790400&sig=...&password=your-password
服务端会先校验密码,再校验 expires 是否过期和 sig 是否匹配。
批量导入
如果已经有一批图片或 PDF,可以用导入脚本生成配置:
node scripts/import-private-content.mjs private-content/inbox --password "your-password" --album portfolio-album --out private-content.local.json
如果希望复制文件并隐藏真实文件名:
node scripts/import-private-content.mjs private-content/inbox --password "your-password" --copy-to private-content/library --out private-content.local.json
如果本地安装了 ImageMagick,还可以同时生成缩略图、水印图和去 EXIF 版本:
node scripts/import-private-content.mjs private-content/inbox --password "your-password" --copy-to private-content/library --thumbs --strip-exif --watermark "colommar.asia"
脚本会生成 hash 型 alias 和 hash 型文件名,避免 URL 或文件名泄露太多上下文。
安全边界
这个方案解决的是“公开博客旁边的轻量私密分享”,不是企业级权限系统。需要明确几个边界:
- URL 里的密码会出现在浏览器历史、代理日志或截图里,适合低频私密分享,不适合高敏感资料。
- 如果仓库是公开的,不应该提交真实私密文件或弱密码哈希。
- 如果文件已经进入 Hugo 的
public/或static/,就不再受网关保护。 private-content.local.json适合本地私密配置,已经加入.gitignore。- 生产部署如果从 Git 构建,部署所需文件必须能被 Vercel Function 打包;如果不想提交私密文件,需要改用本地 CLI 部署或其他私有构建方式。
小结
这次改造保留了静态博客的简单性,同时给个人图床、简历 PDF、作品集相册、私密 Markdown 笔记留出了一条独立入口。公开内容仍然走 Hugo,私密内容统一走 /p/:id 网关。
后续如果需求继续变重,可以再扩展访问日志、一次性链接、IP 限制、文件级审计、加密文件存储等能力。但对个人博客来说,现在这套已经覆盖了最常见的私密展示和临时分享场景。