上一篇文章里,我写了足迹公开页是怎么做出来的:Hugo 读取 data/footprints.yaml,把地点数据嵌进页面,再交给 TypeScript 初始化腾讯地图、地点列表和照片灯箱。
这篇讲它后面的管理功能。整个功能的核心入口不是公网域名,而是 Tailscale:我的手机、电脑和服务器登录同一个 Tailscale 账户,设备之间先通过 tailnet 互联,然后手机才能进入服务器上的足迹管理页面。这样做的好处是,管理入口不需要暴露在博客公网域名上。
公开页做完之后,真正麻烦的部分才开始出现:我不想每次拿手机拍完照片,都要先把文件传到电脑,再手动处理图片、填写 YAML、重新构建博客。于是我把上传、地点解析、数据更新和发布串成了一条只服务于自己生活记录的技术链路。
它看起来是一个上传页面,实际上是一条小型发布流水线:手机通过 Tailscale 进入私有入口,输入或读取照片地点,服务处理图片,修改 Hugo 数据,构建并发布站点。中间任何一环出错,都应该明确告诉我,而不是给我一个“好像上传成功了”的按钮状态。
我没有把上传接口放到公网
后台的第一个约束是:它不应该成为博客的公开功能。
博客公开页面仍然由 Nginx 提供,足迹上传服务则是一个单独的 Flask 进程,默认只监听本机回环地址:
127.0.0.1:8791
systemd 单元里明确写了运行用户和监听方式:
[Service]
User=ubuntu
Group=ubuntu
EnvironmentFile=/etc/footprints-upload.env
ExecStart=/usr/bin/python3 /home/ubuntu/blog-src/scripts/footprints_upload.py
这里有两个好处。
第一,公网 Nginx 根本无法把这个端口暴露出去。第二,上传服务不是 root 进程,即使代码出现问题,默认权限也被限制在博客用户和指定图片目录内。
我还在公网 Nginx 里显式拒绝了这两个路径:
location ^~ /footprints-admin/ { return 404; }
location ^~ /api/footprints/ { return 404; }
之前 Nginx 对未知路径会回退到首页,访问后台路径虽然看不到后台内容,却会返回 200。这不算真正的泄露,但会让排查和安全判断变得模糊,所以我最后让它明确返回 404。
Tailscale 解决的是“谁能碰到后台”
只监听 127.0.0.1 之后,手机自然访问不到。这里我没有给后台再做一个公网域名,而是使用 Tailscale Serve:
https://tokyo-exit-node.tailde6fbb.ts.net:8443/footprints-admin/
Tailscale 在这条链路里的价值,不只是“有一个 VPN IP”。它把后台入口限制在我的 tailnet 内:手机必须先登录同一个 Tailscale 网络,外部互联网用户即使知道 URL,也无法建立这条私有连接。
但我很快遇到一个实际问题:博客的 Nginx 已经占用了公网 443 端口。最初我把 Serve 配到 443,配置状态看起来是成功的,服务器本机访问却仍然命中了 Nginx,手机端表现为页面卡住。
最后我把 Serve 改到 8443:
tailscale serve --bg --https=8443 http://127.0.0.1:8791
改完以后,用完整端口访问,服务端返回才稳定命中 Flask 后台。这里的经验很直接:看到 tailscale serve status 显示 proxy,并不等于你从实际客户端访问时一定经过了它,端口占用和本机网络路径都需要验证。
Tailscale 不是唯一一道门
Tailscale 解决的是网络范围,但我仍然给后台加了独立账号密码。
Flask 在 /footprints-admin/ 和 /api/footprints 前做 Basic Auth,密码只以 Werkzeug 哈希保存在:
/etc/footprints-upload.env
这个文件是 root:root、0600 权限,仓库里不保存明文密码。上传接口还要求:
X-Requested-With: XMLHttpRequest
这不是完整的复杂会话系统,但可以挡住普通跨站表单直接提交;后台页面通过同源 fetch 自己带上这个请求头。
所以现在的访问链路是两层:
手机登录 Tailscale
↓
进入 tailnet 私有 HTTPS
↓
Basic Auth 账号密码
↓
后台页面和上传 API
对我这种单用户、低频、只给自己用的后台来说,这比把上传接口直接挂在博客公网域名下更合适。它减少了公网攻击面,也不需要先为一个小工具搭完整的用户系统。
每张图片都要有自己的地点
最初的上传页只有一个文件选择框。第一次实际用手机测试时,我发现“文件上传成功”不等于“足迹完成了”。图片目录里有了 WebP,但地图完全没有变化。
原因很简单:地图读取的是 data/footprints.yaml,它需要地点、坐标、日期和照片之间的关系。单独把图片放到 static/images/footprints/,Hugo 不会凭空知道它应该落在哪个坐标。
所以现在上传页会为每张图片生成一个地点输入框:
照片 A 地点:泸沽湖
照片 B 地点:
照片 C 地点:香港大学梅堂
后台接收的地点数组和图片顺序一一对应:
images = [a.jpg, b.jpg, c.jpg]
locations = ["泸沽湖", "", "香港大学梅堂"]
手动填写地点时,服务端调用腾讯位置服务的地址解析接口,将文字转换为经纬度和省市信息。留空时,后台读取照片的 EXIF GPS;像最初那张香港大学梅堂的照片,就可以直接从原图拿到:
date: 2017-01-16
lat: 22.282116...
lng: 114.139808...
这两个分支的优先级很明确:
手动地点
↓ 有
腾讯地理编码
手动地点为空
↓ 有
EXIF GPS
两者都没有
↓
这一张失败,其他图片继续
“其他图片继续”很重要。批量上传 10 张照片时,不能因为其中一张没有 GPS,就把另外 9 张也全部回滚。接口现在会返回成功图片、失败文件名和失败原因,前端再用弹窗告诉我本次是全部成功还是部分成功。
图片处理和地点数据现在是一件事
每张图片成功后,后台会完成几步处理:
- 检查扩展名、MIME 类型和真实文件签名;
- 根据 EXIF 修正方向;
- 生成最长边 2560 像素的展示图;
- 生成最长边 640 像素的缩略图;
- 删除公开版本中的 EXIF 和 GPS;
- 生成随机图片 ID;
- 组合地点、日期、坐标和照片 URL 的 YAML。
现在返回的 YAML 已经不是单独的一段图片地址,而是可以直接进入 locations 数组的完整地点:
- id: fp-example
name: "泸沽湖"
date: ""
lat: 27.6894
lng: 100.7798
country: "中国"
province: "云南省"
city: "丽江市"
category: ""
category_name: ""
description: ""
cover: /images/footprints/2026/09/fp-example-thumb.webp
photos:
- src: /images/footprints/2026/09/fp-example.webp
thumb: /images/footprints/2026/09/fp-example-thumb.webp
alt: "泸沽湖"
caption: ""
上传成功之后,为什么还要自动发布
一开始我认为“把 YAML 片段返回给我,手动粘贴一下”也可以。但既然已经决定用手机管理图片,再让我回电脑改数据、跑 Hugo,就等于把最麻烦的步骤留在了后台之外。
所以现在成功上传后,服务会自动把地点追加到:
data/footprints.yaml
然后依次执行:
检查文章是否有未来发布时间
↓
Hugo 构建 public/
↓
root-only helper 同步 /var/www/blog/
↓
线上足迹地图读取新的地点数据
这里没有给上传服务通用的 root 权限。它只能通过 sudoers 调用一个 root-owned helper,而这个 helper 只做一件事:
rsync -a --delete /home/ubuntu/blog-src/public/ /var/www/blog/
上传服务本身不能执行任意 root 命令,也不会自动提交 Git。这样做的代价是发布链路更长,但权限边界更清楚。
过程中踩到的坑
第一个坑是上传按钮看起来没有反应。实际上服务已经返回了 200,只是页面没有弹窗,也没有把结果滚动到视野内。我连续点了几次,结果同一批照片被上传了三遍。
后来我按内容哈希清理了重复文件,并给前端加上了:
- 上传中状态;
- 按钮禁用;
- 120 秒超时;
- 成功、部分成功、失败弹窗;
- 自动滚动到 URL 和 YAML 结果。
第二个坑是腾讯位置服务的 WebService 配额。腾讯地图 JavaScript SDK 能正常加载,并不代表同一个 Key 的服务端地址解析额度一定够用。测试“泸沽湖”时,我手动输入地点后直接报错,排查才发现:在腾讯位置服务控制台创建 Key 还不够,还需要给这个 Key 对应的 WebService 能力分配调用额度。没有额度时,接口明确返回每日调用量已达到上限。
这类错误不能被吞掉。现在它会作为某一张图片的地点解析失败返回,其他带 EXIF GPS 或已经解析成功的图片仍然可以继续。等配额恢复,或者调整 Key 的 WebService 配额,手动地点解析就可以继续使用。
第三个坑是“图片上传成功”和“地图出现地点”其实是两个不同的成功标准。前者只说明文件被处理了,后者还要求地点数据成功写入、Hugo 构建成功、静态站点同步成功。现在后台把自动发布失败单独返回并弹窗提示,不再把“图片已写入磁盘”冒充成“地图已经上线”。
最后
这个后台最后没有变成一个很大的系统:没有数据库,没有用户注册,也没有把公开足迹页改成动态服务。
它只是把几个边界清楚的组件串了起来:
手机照片
↓
Tailscale 私有入口
↓
Basic Auth 后台
↓
EXIF / 腾讯地理编码
↓
WebP 图片处理
↓
footprints.yaml
↓
Hugo + root-only 发布 helper
↓
公开足迹地图
我现在对这类后台的判断也变得更具体了:真正难的不是加一个上传按钮,而是把“上传成功”“数据正确”“权限合理”“发布完成”这几件事连成一条可以验证的链路。
如果其中任何一步只能靠我凭感觉确认,那它就还没有真正做完。