足迹后台的实现:设备互联、地点解析与自动发布

记录足迹管理功能如何通过 Tailscale、Basic Auth、EXIF 和自动发布链路,把一张手机照片真正接到地图上。

上一篇文章里,我写了足迹公开页是怎么做出来的: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 张也全部回滚。接口现在会返回成功图片、失败文件名和失败原因,前端再用弹窗告诉我本次是全部成功还是部分成功。

图片处理和地点数据现在是一件事

每张图片成功后,后台会完成几步处理:

  1. 检查扩展名、MIME 类型和真实文件签名;
  2. 根据 EXIF 修正方向;
  3. 生成最长边 2560 像素的展示图;
  4. 生成最长边 640 像素的缩略图;
  5. 删除公开版本中的 EXIF 和 GPS;
  6. 生成随机图片 ID;
  7. 组合地点、日期、坐标和照片 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
  ↓
公开足迹地图

我现在对这类后台的判断也变得更具体了:真正难的不是加一个上传按钮,而是把“上传成功”“数据正确”“权限合理”“发布完成”这几件事连成一条可以验证的链路。

如果其中任何一步只能靠我凭感觉确认,那它就还没有真正做完。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计
参考 iOS 26 液态玻璃风格改造