适用场景:什么时候该把上传当成生产入口

Flask 博客早期只要能在后台插图、保存封面、前台正常展示,上传功能就算完成了第一阶段。但当站点开始稳定更新、后台不止一个人使用、线上接入 Nginx 和 Gunicorn、static/uploads/ 里积累了几个月的文件以后,上传就不再只是编辑辅助功能,而是一个真实的生产入口。它会影响磁盘、CPU、静态资源路径、日志质量和后台权限边界。

Stepnex 当前项目已经有一条不错的基础链路:MAX_CONTENT_LENGTH 控制请求体上限,ALLOWED_IMAGE_EXTENSIONS 控制扩展名,secure_filename() 清理原始文件名,最终文件再改成 UUID,Pillow 负责读取、缩放和转码,上传目录按 static/uploads/YYYYMM/ 分层。问题不在于“功能能不能用”,而在于“出了问题能不能挡住、能不能定位、能不能复验”。

如果你最近已经处理过 Flask 博客后台权限最小化:管理员/作者分离、装饰器校验与 Nginx 二次防护,这篇文章可以直接接上。权限解决谁能进后台,上传安全解决进入后台后的文件请求最多能把系统推到什么边界。

技术取舍:先收紧边界,再谈重型方案

图片上传很容易一步走偏:对象存储、异步任务、杀毒引擎、审核服务全都想上,结果基础边界反而模糊。对个人博客或小团队维护的 Flask 站点,更稳的顺序通常是:先收紧请求体大小、允许格式、文件名、落盘目录、像素尺寸和后台权限;再补日志、验证命令和复盘清单;最后再决定是否需要对象存储、多机同步或异步处理。

这里有四个长期有效的取舍。第一,扩展名白名单只能当第一道门,不能当最终判定。第二,文件名永远不可信,哪怕已经做了 secure_filename(),最终落盘名也仍然应该随机化。第三,Flask 限制和 Nginx 限制都要有,前者让代码知道上限,后者让超大请求尽量在代理层就被拦下。第四,日志只记录排查需要的信号,不记录整份敏感内容。

步骤一:把基础可用链路写成明确配置

先把约束集中到配置里,而不是散落在模板、JavaScript 和视图函数里。这个项目的上传关键项已经在 config.py

MAX_CONTENT_LENGTH = int(os.getenv("MAX_CONTENT_LENGTH", 8 * 1024 * 1024))
UPLOAD_FOLDER = str(BASE_DIR / "static" / "uploads")
ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
IMAGE_MAX_DIMENSION = int(os.getenv("IMAGE_MAX_DIMENSION", 1600))
IMAGE_JPEG_QUALITY = int(os.getenv("IMAGE_JPEG_QUALITY", 82))
IMAGE_WEBP_QUALITY = int(os.getenv("IMAGE_WEBP_QUALITY", 82))

然后在 Nginx 给 /admin/upload 单独设体积限制,不要把全站都按同一个请求上限处理:

location /admin/upload {
    client_max_body_size 8m;
    proxy_pass http://127.0.0.1:5000;
    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;
}

改完立刻跑命令确认配置仍然可用:

sudo nginx -t
sudo systemctl reload nginx
curl -I https://stepnex.cn/admin/login

这一步的验证重点不是“能不能传图”,而是“代理层配置有没有误伤后台入口”。很多线上事故不是图片本身有问题,而是一条 location 改动把上传路由代理坏了。

步骤二:把扩展名白名单和随机文件名当成最小基线

应用层第一道门应该足够简单且明确。当前项目已经有:

def allowed_file(filename: str) -> bool:
    suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
    return suffix in Config.ALLOWED_IMAGE_EXTENSIONS

这段逻辑的价值不在复杂,而在清楚。它能立即挡掉明显不该进入图片链路的文件,也能让日志里的失败原因足够可读。

但只靠扩展名绝对不够。真实落盘时仍然应该先清理原始文件名,再完全放弃原始名字:

original_name = secure_filename(file_storage.filename)
suffix = Path(original_name).suffix.lower()
month_dir = datetime.utcnow().strftime("%Y%m")
filename = f"{uuid.uuid4().hex}{suffix}"
save_path = upload_dir / filename

secure_filename() 解决的是危险字符和跨平台路径问题,UUID 解决的是重名覆盖、可枚举 URL 和业务语义泄露。两者不是二选一。原始文件名如果要保留,更适合进数据库元数据,不适合直接变成公开访问路径。

步骤三:用 Pillow 做内容校验、尺寸收缩和重编码

扩展名像图片,不代表内容真的是图片。更稳的方式是让 Pillow 真正打开一次文件。当前项目的处理链路已经很接近生产可用:

with Image.open(file_storage.stream) as image:
    image.load()
    if suffix == ".gif":
        file_storage.stream.seek(0)
        file_storage.save(save_path)
    else:
        image = ImageOps.exif_transpose(image)
        image.thumbnail((Config.IMAGE_MAX_DIMENSION, Config.IMAGE_MAX_DIMENSION))
        if image.mode not in {"RGB", "RGBA"}:
            image = image.convert("RGB")
        image.save(save_path, **save_kwargs)

这条链路已经解决了三件重要的事:图片内容会被真实解析;非 GIF 会经过统一重编码;尺寸会被压到可控上限。对博客场景,这三点比“保留原图原样”更稳定。

如果要继续加固,建议补一层预检,把损坏图片和像素炸弹更早挡掉:

import warnings
from PIL import Image

warnings.simplefilter("error", Image.DecompressionBombWarning)

with Image.open(file_storage.stream) as probe:
    probe.verify()

file_storage.stream.seek(0)
with Image.open(file_storage.stream) as image:
    image.load()

这里的技术取舍是:verify() 更适合做预检,load() 更适合进入后续缩放和转码流程。验证方式不要只传一张正常图,还要故意传一张损坏文件和一张像素很大的测试图,确认错误能稳定落到同一个分支。

步骤四:补齐后台权限、CSRF 和日志

上传入口是 /admin/upload,所以它首先是后台权限问题。当前项目已经有登录校验和 CSRF 检查:

@app.route("/admin/upload", methods=["POST"])
@login_required
def admin_upload():
    if not validate_csrf(header=True):
        abort(400)
    file = request.files.get("image")

这说明基础防线方向是对的,但长期维护还要补两件事。第一,上传失败要有日志,不然线上看到 400 还是不知道是后缀不允许、Pillow 校验失败还是写盘异常。第二,上传成功也要留审计信号,至少能看出是谁、什么时候、在什么路径上传过。

一个克制但够用的日志模板可以是:

current_app.logger.info(
    "admin_upload result=%s user_id=%s content_length=%s path=%s",
    result,
    current_user.id,
    request.content_length,
    request.path,
)

如果未来后台角色继续细分,上传权限要和文章编辑权限一起审视,而不是默认所有登录用户都能传图。日志的价值就在这里:它让你能把权限、失败率和异常请求联系起来看,而不是事后凭感觉排查。

性能与安全:别把问题留到高峰期才看

上传问题常常先表现为性能抖动,再被误以为只是网络波动。典型信号包括:Nginx 或 Flask 频繁返回 413;超大尺寸图片让 Gunicorn worker 解码过慢;编辑器反复重试把并发放大;图片虽然上传成功,但前台体积过大,拖慢页面展示。

因此验证时必须把命令和日志一起看:

curl -i -b cookie.txt -c cookie.txt -F "image=@test.webp" https://stepnex.cn/admin/upload
curl -i -b cookie.txt -F "image=@oversize.jpg" https://stepnex.cn/admin/upload
sudo tail -n 50 /var/log/nginx/access.log
sudo tail -n 50 /var/log/nginx/error.log
journalctl -u blog -n 100 --no-pager

验证重点不是“有没有 200”,而是超限上传、伪装上传、损坏上传三种场景下,前端提示、应用日志和 Nginx 日志是否一致。日志如果不能区分这些分支,后续排查成本会非常高。

自动化与维护:把验证动作写进发布节奏

长期维护最怕系统只存在于记忆里。更稳的做法,是把上传复查写成固定动作。每次上线后至少做三件事:传一张正常图、传一张超限图、传一个改后缀的伪装文件;然后看日志是否清楚区分成功、超限和无效图片。

如果你已经在做自动化发布,还可以增加很轻量的预检:确认 MAX_CONTENT_LENGTH 已配置、上传目录存在、Nginx 里有 /admin/upload 的单独限制、最近 24 小时日志里没有异常集中的 invalid image。这种自动化不需要很重,但对避免回归非常有效。

另外别忘了把上传目录和数据库一起纳入备份与恢复演练。和 SQLite 博客备份怎么做才可靠:文件复制、校验和恢复演练 一样,图片文件本身也是内容资产,不是“有数据库就够了”。

避坑点与复盘清单

先说避坑点。不要把扩展名白名单当成完整安全策略;不要直接使用原始文件名当最终路径;不要只在 Flask 限大小而放任 Nginx 接收超大请求;不要把所有失败都压成同一个笼统 400;不要忽略 GIF 与其他格式不同的处理路径;也不要只看应用日志而忽略 Nginx 的静态访问日志。

再说复盘。上线前至少完成下面这组验证方式:

  1. 正常上传一张 JPEG,确认后台返回成功、前台 URL 可访问。
  2. 上传一张超过 client_max_body_size 的文件,确认返回 413 或清晰错误,而不是 500。
  3. 把非图片文件改名为 .png 后上传,确认 Pillow 校验失败并留下日志。
  4. 上传一张像素极大的测试图,确认尺寸上限或像素炸弹保护按预期工作。
  5. 在测试环境故意改坏上传目录权限,确认写盘失败能在日志里被看见。

每周复盘时只问五件事:上传失败比例是否上升;失败主要集中在哪个阶段;上传目录体积是否异常增长;是否出现可疑账号操作;最近一次部署有没有动过上传配置、静态路径或目录权限。这样梳理后,图片上传就不再只是“后台里能用的一个按钮”,而是一条基础可用、生产加固、性能与安全、自动化与维护、复盘清单都齐全的长期工程链路。