我一直希望这个博客除了放公开文章,也能顺手承担一些个人内容的展示需求,有些图片、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 版本
  • 对外只暴露 /p/trip-2026-cover 这类 alias,真实文件名不会跟着出现在 URL 里

架构选择

实际落地时,我增加了一个 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,外部访问者能看到这个名字,却拿不到真实文件名。

直接预览时使用:

/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 限制、文件级审计和加密文件存储等能力,不过对个人博客目前的使用量来说,这一版已经能覆盖常见的私密展示与临时分享场景,没有必要一开始就把系统做得太重。