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
12from flask import flash, get_flashed_messages
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
3POST /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
<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是空的,视图直接走「上传失败」分支。
视图
1 | import os |
-
实测这一套的行为:
1
2
3
4GET /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.pngshot.png../../etc/passwdetc_passwdC:\Users\me\照片.pngC_Users_me_.pnga b&c.pnga_bc.png....//x.pngx.png中文名.pngpng -
最后两行要注意两件事:中文会被整个丢掉(
中文名.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
3from 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
10from 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
25import os
import secrets
from PIL import Image
from flask import flash, render_template, send_from_directory
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)
def uploaded(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename) -
上传目录不能直接挂到
static上时就用send_from_directory,实测整套跑通:1
2
3
4POST 合法 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 filethumbnail是等比缩放且只缩不放,结果尺寸是 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
9from flask_moment import Moment
moment = Moment(app)
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
4const 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
3zh-CN -> 2026年11月6日星期五 16:30
en-US -> Nov 6, 2026, 4:30:00 PM
本机时区 -> Asia/Shanghai服务端给的是
08:30Z,浏览器按本机时区自动换成16:30,这正是老笔记用moment.js想达到的效果,一行 JS 就够。