受保护的海报图片读取方案
本文说明当前项目如何在不公开 output/ 静态资源目录的前提下,让浏览器通过后端读取已生成的海报图片。
目标
- 生成的海报文件保存在服务器本地磁盘。
- 外网不能直接通过
/output/...URL 访问真实文件。 - 用户只能通过后端接口
/posters/user/...读取自己账号下的海报。 - Docker 容器重启、镜像更新后,已生成图片不会丢失。
存储目录
本地开发默认目录:
text
<project-root>/output/posters/user/
生产环境推荐目录:
text
/root/maptoposter/output/posters/user/
Docker 容器内目录:
text
/app/output/posters/user/
通过 docker-compose.yml 挂载:
yaml
services:
maptoposter-backend:
volumes:
- /root/maptoposter/output:/app/output
environment:
- MAPTOPOSTER_OUTPUT_DIR=/app/output
这样后端容器写入 /app/output/posters/user/a.png 时,宿主机实际文件会落到:
text
/root/maptoposter/output/posters/user/a.png
容器里看到的是 /app/output,但真实文件存在宿主机 /root/maptoposter/output。
所以海报不会写进 Docker 镜像,也不会让镜像变大;也不会随着容器删除而丢失。它是宿主机目录挂载进去的。
只有不挂载 volume 时,容器内写入的文件才会存在于容器文件系统里,并且容器删除后丢失。
不公开 output 目录
不要在 OpenResty/Nginx 中配置:
nginx
location /output/ {
alias /root/maptoposter/output/;
}
后端返回给前端的图片地址固定是受保护路由:
text
/posters/user/<filename>
生成海报时的流程
text
POST /api/posters
生成海报后,先生成受保护 URL,再把文件保存到输出目录,最后把记录写入数据库:
python
local_poster_path = result.path
stored_poster_url = poster_url("user", result.path.name)
poster_payload = sample_payload(
path=result.path,
url=stored_poster_url,
city=result.city,
country=result.country,
theme=result.theme_slug,
generated_at=generated_at,
latitude=result.point[0],
longitude=result.point[1],
)
upload_user_poster(result.path)
account_payload = append_poster_record(token, poster_payload)
图片 URL 固定生成 /posters/...,不会生成 /output/...:
python
def poster_url(kind: str, filename: str) -> str:
relative_key = _clean_relative_key(f"posters/{kind}/{filename}")
return f"/{relative_key}"
同一个文件里,真实文件只复制到配置好的输出目录:
python
def upload_user_poster(local_path: Path) -> str:
relative_key = _user_poster_relative_key(local_path)
target = _storage_path(relative_key)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(local_path, target)
return relative_key
流程:
generate_poster_payload()校验主题、坐标、字体和用户积分。build_poster()在临时目录生成图片文件。poster_url("user", filename)生成前端可用 URL:/posters/user/<filename>。upload_user_poster(local_path)把临时文件复制到输出目录:
text
output/posters/user/<filename>
-
append_poster_record()把海报记录写入数据库,包括:- 文件名
- 城市
- 国家
- 主题
- URL
- 经纬度
- 所属用户账号
-
临时文件被删除,永久文件保留在
output/挂载目录中。
浏览器读取图片时的流程
前端拿到的用户海报 URL 类似:
text
/posters/user/noir-china-beijing-39.9042,116.4074.png
前端会识别 /posters/user/ 这种受保护图片地址,并带上用户 token 请求图片:
http
GET /posters/user/<filename>
X-Poster-Token: <access-token>
text
GET /posters/{poster_path:path}
路由会先区分 demo(用于展示的图片目录) 和 user 图片。用户图片必须带 token:
python
if not normalized_path.startswith("user/"):
raise HTTPException(status_code=404, detail="Poster file not found.")
access_token = (x_poster_token or poster_access_token or "").strip()
if not access_token:
raise HTTPException(
status_code=401,
detail="Enter the studio before opening this poster.",
)
随后检查数据库,确认该图片属于当前用户:
python
allowed = can_access_poster_file(access_token, normalized_path)
if not allowed:
raise HTTPException(status_code=404, detail="Poster file not found.")
权限通过后才读取本地文件并返回图片内容:
python
stored_poster = read_poster(normalized_path)
if stored_poster is None:
raise HTTPException(status_code=404, detail="Poster file not found.")
return Response(
content=stored_poster.content,
media_type=stored_poster.media_type,
)
权限判断的核心是当前账号、文件名、URL 三者同时匹配:
python
statement = (
select(PosterRecord.id)
.where(
PosterRecord.account_id == user.id,
PosterRecord.filename == filename,
PosterRecord.url.in_(poster_urls),
)
.limit(1)
)
return session.execute(statement).first() is not None
读取时会把 /posters 相对路径转换为输出目录里的真实文件路径:
python
def read_poster(poster_path: str) -> StoredPoster | None:
return read_output_file(_poster_relative_key(poster_path))
读取流程:
_normalize_poster_path()先清理路径,拒绝空路径、.、..等非法路径。- 如果路径是
demo/...,直接读取 demo 图片。 - 如果路径不是
user/...,返回 404。 - 对
user/...图片,后端从请求头或 cookie 中取访问 token。 can_access_poster_file(token, normalized_path)查询数据库,确认这个文件属于当前用户。- 权限通过后,
read_poster(normalized_path)从本地输出目录读取真实文件。 - 后端把图片 bytes 作为响应返回给浏览器。
外网用户即使猜到了文件名,如果没有正确 token 和数据库记录,也不能通过 /posters/user/... 读取图片。
OpenResty 配置
OpenResty 只需要把 /posters/ 转发给 FastAPI 后端,不要映射到真实磁盘目录。
nginx
location ^~ /posters/ {
proxy_pass http://127.0.0.1:8011;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
add_header Cache-Control no-cache;
}
如果站点本身已经有:
nginx
location ^~ / {
proxy_pass http://127.0.0.1:8011;
}
那么 /posters/ 已经会被代理到后端。单独加 /posters/ 的好处是配置更清楚,也方便后续单独调整图片读取超时、缓存策略等。
安全边界
这个方案依赖三层保护:
- 真实文件目录不在 Web 静态目录下。
- OpenResty 不配置
/output/静态访问。 - FastAPI 的
/posters/user/...路由会检查用户 token 和数据库记录。
常见问题
浏览器打开 /posters/user/... 返回 401
说明请求没有带用户 token。正常页面里由 PosterImage.vue 自动带上 X-Poster-Token,直接在浏览器地址栏打开受保护图片通常会缺少这个请求头。
返回 404
常见原因:
- 文件不在
output/posters/user/下。 - 数据库中没有当前用户对应的海报记录。
- URL 文件名和数据库记录中的
filename不一致。 - Docker volume 没挂载,容器内写入路径和宿主机路径不是同一个目录。
返回 503
常见原因:
MAPTOPOSTER_OUTPUT_DIR指向的目录无法创建。- 容器对挂载目录没有写入或读取权限。
- 输出目录被误删或挂载失败。