我一直希望这个博客除了放公开文章,也能顺手承担一些个人内容的展示需求,有些图片、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 版本
- 对外只暴露
/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 限制、文件级审计和加密文件存储等能力,不过对个人博客目前的使用量来说,这一版已经能覆盖常见的私密展示与临时分享场景,没有必要一开始就把系统做得太重。