受保护的海报图片读取方案 - 在不公开静态资源目录下如何获取静态资源

受保护的海报图片读取方案

本文说明当前项目如何在不公开 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

流程

  1. generate_poster_payload() 校验主题、坐标、字体和用户积分。
  2. build_poster() 在临时目录生成图片文件。
  3. poster_url("user", filename) 生成前端可用 URL:/posters/user/<filename>
  4. upload_user_poster(local_path) 把临时文件复制到输出目录:
text 复制代码
output/posters/user/<filename>
  1. append_poster_record() 把海报记录写入数据库,包括:

    • 文件名
    • 城市
    • 国家
    • 主题
    • URL
    • 经纬度
    • 所属用户账号
  2. 临时文件被删除,永久文件保留在 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))

读取流程:

  1. _normalize_poster_path() 先清理路径,拒绝空路径、... 等非法路径。
  2. 如果路径是 demo/...,直接读取 demo 图片。
  3. 如果路径不是 user/...,返回 404。
  4. user/... 图片,后端从请求头或 cookie 中取访问 token。
  5. can_access_poster_file(token, normalized_path) 查询数据库,确认这个文件属于当前用户。
  6. 权限通过后,read_poster(normalized_path) 从本地输出目录读取真实文件。
  7. 后端把图片 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/ 的好处是配置更清楚,也方便后续单独调整图片读取超时、缓存策略等。

安全边界

这个方案依赖三层保护:

  1. 真实文件目录不在 Web 静态目录下。
  2. OpenResty 不配置 /output/ 静态访问。
  3. 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 指向的目录无法创建。
  • 容器对挂载目录没有写入或读取权限。
  • 输出目录被误删或挂载失败。
相关推荐
思绪无限1 小时前
YOLOv5至YOLOv12升级:农作物害虫检测系统的设计与实现(完整代码+界面+数据集项目)
人工智能·python·深度学习·目标检测·计算机视觉·yolov12·农作物害虫检测
我母鸡啊2 小时前
软考架构师故事系列-嵌入式技术
后端
逻辑驱动的ken2 小时前
Java高频面试考点场景题11
java·深度学习·面试·职场和发展·高效学习
码界筑梦坊2 小时前
94-基于Python的商品物流数据可视化分析系统
开发语言·python·mysql·信息可视化·数据分析·毕业设计·fastapi
元Y亨H2 小时前
Python 获取 Windows 设备信息笔记
windows·python
微刻时光2 小时前
影刀RPA:For循环与ForEach循环深度解析与实战指南
人工智能·python·低代码·自动化·rpa·影刀实战
MXN_小南学前端2 小时前
Vue3 + Spring Boot 工单系统实战:用户反馈和客服处理的完整闭环(提供gitHub仓库地址)
前端·javascript·spring boot·后端·开源·github
JavaGuide2 小时前
太魔幻了!SpaceX官宣600 亿美元收购Agent编程的鼻祖Cursor
人工智能·后端
KIHU快狐2 小时前
快狐KIHU|110寸壁挂触控一体机G+G电容屏安卓系统汽车展厅查询展示
android·python·汽车