Flask-文件上传

这篇讲什么

  • 先讲 flash 提示条(配合 Bootstrap 5 的可关闭样式),再讲文件上传三件套:表单的 enctype、request.files、secure_filename。
  • 覆盖 MAX_CONTENT_LENGTH 限流、FileStorage.save()、用 Pillow 生成缩略图的完整例子,以及 flask-moment 把 UTC 时间按浏览器本地化。
  • 老笔记依赖的 flask-uploads 已经装不上(secure_filename 从 werkzeug 搬走了),这一节改成现状写法。

flash消息

  • 用户状态变化时要给提示(成功、警告),通常做成能手动关掉的提示条。flash 存消息,get_flashed_messages 取消息。

  • flash(消息, 类别) 的第二个参数就是类别,取的时候可以按类别过滤。实测:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from flask import flash, get_flashed_messages

    @app.route('/flash', methods=['GET', 'POST'])
    def flashview():
    if request.method == 'POST':
    flash('操作完成', 'success')
    flash('注意磁盘快满了', 'warning')
    return '已 flash'
    return 'with_categories=%r | category_filter=%r' % (
    get_flashed_messages(with_categories=True),
    get_flashed_messages(category_filter=['success']),
    )
    1
    2
    3
    POST /flash  -> '已 flash'
    随后 GET -> with_categories=[('success', '操作完成'), ('warning', '注意磁盘快满了')] | category_filter=['操作完成']
    再 GET 一次 -> with_categories=[] | category_filter=[]
  • 参数名是 with_categories,不是老资料里的 with_context:实测写 with_context=True 直接 TypeError: get_flashed_messages() got an unexpected keyword argument 'with_context'。

  • 消息取一次就没了(它存在 session 里,取走即清空),所以刷新页面不会重复弹。

  • 模板里按类别套 Bootstrap 5 的样式:

    1
    2
    3
    4
    5
    6
    {% for cat, message in get_flashed_messages(with_categories=true) %}
    <div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
    {{ message }}
    <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="关闭"></button>
    </div>
    {% endfor %}

    老笔记那段 {% extends 'bootstrap/base.html' %}、class="close" + data-dismiss 都是 flask-bootstrap 和 Bootstrap 3 的写法,现在都不用了。

  • 不传类别时默认类别是 message,用 with_categories 取出来就是 ('message', '...')。

原生的文件上传

表单

  • 三个硬要求:方法必须是 POST、必须有 enctype="multipart/form-data"、<input type="file"> 必须有 name。

    templates/upload.html
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>文件上传</title>
    </head>
    <body>
    <form method="post" action="" enctype="multipart/form-data">
    <input type="file" name="photo">
    <input type="submit" value="上传">
    </form>
    </body>
    </html>
  • 少一条都收不到文件。实测把字段用普通 urlencoded 方式提交,request.files 是空的,视图直接走「上传失败」分支。

视图

app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import os
from flask import Flask, request
from werkzeug.utils import secure_filename

ALLOWED_EXTENSION = {'png', 'jpg', 'jpeg', 'gif'}

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'uploads')
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 # 1MB


def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSION


@app.route('/upload/', methods=['GET', 'POST'])
def upload():
if request.method == 'GET':
return render_template('upload.html')

file = request.files.get('photo')
if file and allowed_file(file.filename):
# 一定要过 secure_filename,别拿浏览器给的原始名字拼路径
name = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], name))
return '上传成功 %s' % name
return '上传失败'
  • 实测这一套的行为:

    1
    2
    3
    4
    GET  /upload                 -> '表单页'
    POST 带 shot.png(白名单内) -> '上传成功 shot.png',落盘 ['shot.png']
    POST 带 evil.py -> '上传失败'
    POST 没有 photo 字段 -> '上传失败'

FileStorage 对象

  • request.files 里每个值都是 Werkzeug 的 FileStorage,实测:

    1
    filename='note.txt' content_type='text/plain' 大小=11 stream=SpooledTemporaryFile

    常用的是 .filename、.content_type、.read()、.save(路径)、.stream。数据先进内存临时文件(SpooledTemporaryFile),超过阈值才落磁盘,所以小文件 .save() 之前不需要自己管临时目录。

  • 取字段同样有 [] 和 .get() 的区别:实测 request.files['不存在的字段'] 抛 BadRequestKeyError(HTTP 400),.get() 返回 None。老笔记用 .get() 是对的。

文件名安全

  • 绝对不要拿浏览器给的 file.filename 直接拼路径,那是经典的目录穿越入口。secure_filename 实测行为:

    原始文件名 secure_filename 结果
    shot.png shot.png
    ../../etc/passwd etc_passwd
    C:\Users\me\照片.png C_Users_me_.png
    a b&c.png a_bc.png
    ....//x.png x.png
    中文名.png png
  • 最后两行要注意两件事:中文会被整个丢掉(中文名.png 只剩 png),而 ..、/ 这类会被清成空串。空串拼进 os.path.join 得到的就是上传目录本身,save() 会报 IsADirectoryError。所以安全做法是别依赖客户端的文件名,自己生成随机名(见「综合上传」)。

  • 后缀判断要先 lower(),实测老笔记那种 in ALLOWED_EXTENSION 遇到 PNG 会漏判。

大小限制

  • MAX_CONTENT_LENGTH 是整个请求体的上限,超了 Werkzeug 直接拒绝,实测:

    1
    2
    传 3KB(上限设成 2KB)-> HTTP 413
    传 100B -> HTTP 200
  • 413 是在进视图之前就抛的,所以视图里写 try/except 接不住,要定制页面得用 @app.errorhandler(413)。

  • 前端也该有一道提示,accept 只限制文件选择框的可选项,大小要靠 JS 自己判:

    1
    <input type="file" name="pic" accept="image/png,image/jpeg">

    但 accept 拦不住改过后缀的文件,也拦不住 curl,服务端那份 MAX_CONTENT_LENGTH 和扩展名白名单才是有效的。

flask-uploads:已经装不上

  • 老笔记整节推荐的 flask-uploads(UploadSet、IMAGES、configure_uploads、patch_request_class)现在连 import 都过不去。实测把 0.2.1 装进独立目录再导入,报:

    1
    ImportError: cannot import name 'secure_filename' from 'werkzeug'

    它依赖的是 Werkzeug 顶层直接导出 secure_filename 的老结构,那个入口在 Werkzeug 1.0 就删了,现在要写 from werkzeug.utils import secure_filename。

  • 它最后一次发版是 0.2.1,patch_request_class 这种函数名也早就和 Flask 的 MAX_CONTENT_LENGTH 重复了,所以这一节的功能全部用上一节的原生写法覆盖即可:白名单用 allowed_file、大小用 MAX_CONTENT_LENGTH、随机名自己生成。

  • 确实需要「按集合管理类型 + 存盘 + 出 URL」这套封装时,社区续命分支 flask-reuploaded 实测可用:1.6.0 能装(pip install --no-deps --target 到独立目录,不污染环境),import 名仍然沿用老的 flask_uploads:

    1
    2
    3
    from flask_uploads import UploadSet, IMAGES, configure_uploads
    UploadSet('photos', IMAGES).extensions
    -> ('jpg', 'jpe', 'jpeg', 'png', 'gif', 'svg', 'bmp', 'webp')

    也就是说老笔记那套 UploadSet / configure_uploads 的代码换个包就能跑。不过 Flask 官方文档里没有推荐任何上传扩展,本笔记的主路径仍是上一节的原生写法;要引这个包,先按「装插件前看 Requires-Dist」的规矩确认它不会动 Flask 的版本。

综合上传

  • 目标:Flask-WTF 收文件 + 校验 + 随机文件名 + Pillow 压缩略图 + flash 提示,模板用 Bootstrap 5。

  • 表单类:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    from flask_wtf import FlaskForm
    from flask_wtf.file import FileAllowed, FileField, FileRequired
    from wtforms import SubmitField

    class UploadForm(FlaskForm):
    photo = FileField('上传头像', validators=[
    FileRequired('文件未选择'),
    FileAllowed(ALLOWED_EXTENSION, '只能上传图片文件'),
    ])
    submit = SubmitField('上传')

    实测 FileField 不带 enctype="multipart/form-data" 时永远收不到文件,FileRequired 会一直报「文件未选择」——这是新手最容易卡住的一处。

  • 视图:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    import os
    import secrets
    from PIL import Image
    from flask import flash, render_template, send_from_directory

    @app.route('/upload/', methods=['GET', 'POST'])
    def upload():
    form = UploadForm()
    img_url = None
    if form.validate_on_submit():
    f = form.photo.data
    suffix = os.path.splitext(f.filename)[1].lower()
    filename = secrets.token_hex(16) + suffix # 随机名,不依赖客户端
    f.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
    with Image.open(os.path.join(app.config['UPLOAD_FOLDER'], filename)) as img:
    img.thumbnail((128, 128))
    img.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
    img_url = url_for('uploaded', filename=filename)
    flash('头像已上传成功', 'success')
    return redirect(url_for('upload')) # PRG,刷新不重复上传
    return render_template('upload.html', form=form, img_url=img_url)

    @app.route('/uploads/<path:filename>')
    def uploaded(filename):
    return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
  • 上传目录不能直接挂到 static 上时就用 send_from_directory,实测整套跑通:

    1
    2
    3
    4
    POST 合法 png -> saved=7e8640bbc592acef6735381c306cc815.png
    GET /uploads/7e8640bbc592acef6735381c306cc815.png -> 200,128 字节
    POST 后缀不在白名单 -> errors={'photo': ['只能图片']}
    POST 不带文件 -> errors={'photo': ['未选择']}
  • 目录穿越在这条路上是安全的:实测 GET /files/../../probe_upload.py 返回 404,send_from_directory 不允许跳出给定目录。

  • 反过来,app.send_static_file('../uploads/x.png') 是不行的,实测抛 NotFound(404)——send_static_file 只在 static 根里找,不会往外走。

  • 随机名用标准库 secrets.token_hex(16) 就够了(实测样例 1807635f2578a86d0c819bb7c4a1dfb9),不必像老笔记那样手搓 random.choice 字符串(那个函数里 base_str 还写错了顺序,tuvwsyz 少了 x)。

  • Pillow 实测(版本 13.0.0):

    1
    2
    3
    4
    原图 PNG 300x200
    thumbnail((128,128)) 之后 128x85
    文件大小 270 字节
    打开非图片 -> UnidentifiedImageError: cannot identify image file

    thumbnail 是等比缩放且只缩不放,结果尺寸是 128x85 而不是 128x128;要正好方形得用 resize 或先 crop。

  • 传上来的文件不一定真是图片,实测打开非图片抛 PIL.UnidentifiedImageError,所以校验后缀之外还要用 Pillow 真打开一次才算数。

  • 模板:

    templates/upload.html
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {% extends 'base.html' %}

    {% block page_content %}
    <form method="post" enctype="multipart/form-data">
    {{ form.hidden_tag() }}
    <div class="mb-3">
    {{ form.photo.label(class='form-label') }}
    {{ form.photo(class='form-control') }}
    {% for e in form.photo.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
    </div>
    {{ form.submit(class='btn btn-primary') }}
    </form>

    {% if img_url %}<img src="{{ img_url }}" alt="头像" class="mt-3">{% endif %}
    {% endblock %}

    注意 enctype 要写在这个 form 上,继承了基础模板不等于自动带上。

flask-moment 与时间本地化

  • 这个扩展的作用是把时间交给浏览器按当地时区显示。老笔记说它「依赖 jQuery」——这句现在不对了:实测 flask-moment 1.0.6 里已经没有 include_jquery()(hasattr(moment, 'include_jquery') 为 False),它只引 moment.js 本体。

  • 装与用:

    1
    pip install flask-moment
    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    from flask_moment import Moment

    moment = Moment(app)

    @app.route('/mom/')
    def mom():
    import datetime
    t = datetime.datetime(2026, 11, 6, 8, 30)
    return render_template('mom.html', t=t)
    templates/mom.html
    1
    2
    3
    4
    5
    6
    {{ moment.include_moment() }}
    {{ moment.locale('zh-CN') }}

    <div>{{ moment(t).format('LLLL') }}</div>
    <div>{{ moment(t).format('YYYY-MM-DD') }}</div>
    <div>{{ moment(t).fromNow() }}</div>
  • 实测渲染出来的 HTML 是「一段 script + 一串带 data 属性的 span」,真正的格式化由浏览器完成:

    1
    2
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment-with-locales.min.js" integrity="sha512-..."></script>
    <div><span class="flask-moment" data-timestamp="2026-11-06T08:30:00Z" data-function="format" data-format="LLLL" data-refresh="0" style="display: none">...</span></div>

    默认引的 moment.js 版本是 2.29.4,带 SRI 校验;moment.locale('zh-CN') 会再生成一行 moment.locale("zh-CN");。

  • 两个实测细节:

    • moment 是 context_processor 注入的,不是 Jinja 全局:'moment' in app.jinja_env.globals 是 False,绕过 render_template 直接 get_template(...).render() 会报 UndefinedError: 'moment' is undefined。
    • 服务端要拿到带时区的时间才谈得上本地化,实测老笔记的 datetime.utcnow() 在本机 Python 3.14 上已经报 DeprecationWarning,改写 datetime.now(datetime.UTC)。
  • 要不要用它:moment.js 官方已经宣布进入维护模式(不再加新特性),Flask 官方文档也没推荐这个扩展。要历史时间差(「3 分钟前」)才考虑它。

  • 新项目更省事的做法是后端只输出 ISO 8601 或时间戳,前端用浏览器自带的 Intl.DateTimeFormat 渲染,不引第三方库。本机 Node 实测:

    1
    2
    3
    4
    const d = new Date('2026-11-06T08:30:00Z');
    new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full', timeStyle: 'short' }).format(d);
    new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'medium' }).format(d);
    Intl.DateTimeFormat().resolvedOptions().timeZone;
    1
    2
    3
    zh-CN -> 2026年11月6日星期五 16:30
    en-US -> Nov 6, 2026, 4:30:00 PM
    本机时区 -> Asia/Shanghai

    服务端给的是 08:30Z,浏览器按本机时区自动换成 16:30,这正是老笔记用 moment.js 想达到的效果,一行 JS 就够。

参考

本文结束 感谢您的阅读
正在加载今日诗词....