我希望这个博客不只是公开文章站,也能承担一部分个人内容展示能力:有些图片、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, nofollowCache-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 限制、文件级审计、加密文件存储等能力。但对个人博客来说,现在这套已经覆盖了最常见的私密展示和临时分享场景。