enctype 必须是 multipart/form-data,少了它服务端只收到文件名。 端点三步:洗名、加前缀、存盘,顺序不能反。 重名是这个功能最真实的坑:用户上传的同名照片,不加前缀就只能留一张。
这一篇对应教材 7.4 到 7.7:建立图片上传页面的表单类别、端点、模板, 以及确认上传页面的运作情况。 上一篇把目录和取图端点备好了,这一篇把文件送进去。
一、表单:三个属性一个都不能少
<form method="post" enctype="multipart/form-data">
<input type="file" name="file">
<button>上传</button>
</form>
method 是 post,这个谁都不会漏。另两个容易漏。 enctype 漏了会怎样:浏览器只发文件名,不发内容。 服务端收到的 request.files 里那个 FileStorage 是空的,filename 还在。 这个症状特别容易误判——名字在、大小是 0,第一反应往往是「保存那一步写错了」,其实是根本没收到内容。
实测带 enctype 的请求,落盘后文件 48 字节;不带的话文件是 0 字节。
二、表单类里能不能校验文件
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms import SubmitField
class UploadForm(FlaskForm):
file = FileField("图片", validators=[
FileRequired("还没选文件"),
FileAllowed(["png", "jpg", "jpeg"], "只收 png 与 jpg"),
])
submit = SubmitField("上传")
FileAllowed 看的是扩展名。这一点要在心里标个星号:它不检查文件内容。 把一张 .exe 改名成 .png,它能过。真要判内容,得读前几个字节看魔数。 教材用的是这套写法,够用;生产环境还需要一步内容检查。
三、端点三步
f = form.file.data
safe = secure_filename(f.filename) or ("image." + ext)
name = uuid.uuid4().hex[:8] + "_" + safe
f.save(os.path.join(UP, name))
三件事:洗名防穿越、加前缀防重名、存盘到配置里那个目录。
顺序不能反:先洗再拼。反过来的话,拼好的名字里那段前缀也会被洗一遍。
实测上传一张 cat.png,返回 302 跳到 /images/,目录里多出 6178c4f3_cat.png,库里多一行记录。
文件名和数据库里存的必须是同一个——只存原始名,取图时就找不到文件了。
四、扩展名白名单要在服务端判
实测传一个 evil.exe,返回 200(不是 302),页面出现「只收 png 与 jpg」,目录里文件数没变。 200 而不是 400,因为它走的是「表单校验没过,重新渲染页面」这条路,跟注册页密码太短是一回事。 前端的 accept 属性只是给文件选择框用的过滤,随手就能改掉。 所以白名单必须在服务端判一遍,前端那份只是省用户的事。
五、超过大小上限会怎样
实测传一张 3MB 的,上限 2MB,返回 413。 413 是「请求体太大」,在进入视图之前就被拦了。所以端点里那句扩展名校验根本没跑到。 这个上限就是配置里那个 MAX_CONTENT_LENGTH。
它是防线,不是建议。不设的话,一次大文件上传就能把内存吃光——Flask 默认会把整个请求体读进内存。 413 的页面是 Flask 自带的,很简陋。要好看的提示就注册一个 413 的错误处理器。
六、同名文件:这一章最真实的坑
实测同一个 cat.png 传两次,目录里是 6178c4f3_cat.png 与 6cb0587f_cat.png,两个不同的名字。
去掉前缀再传两次同一个 dog.png,目录里叫 dog.png 的只剩 1 个。
后果不止是文件被覆盖:数据库里两条记录指着同一个文件。删掉其中一条,另一条的图就没了。
用户上传的照片十有八九叫 IMG_0001.jpg 或者 微信图片.jpg,撞名是常态不是例外。 前缀用 uuid 前 8 位就够了:四千多万种,够用;长了文件名难看。
七、验收怎么走
| 步骤 | 期望 |
|---|---|
| 打开上传页 | 200,有文件选择框 |
| 传一张 png | 302 到列表页,目录多一个文件,库里多一行 |
| 传一个 exe | 200,页面提示只收 png 与 jpg,目录不变 |
| 传 3MB 的 png | 413 |
| 同一个名字传两次 | 两个文件都在,名字不同 |
| 列表页 | 能看到全部,缩略图能显示 |
六步走完,这个功能才算能用。 光看「能传上去」不够——恶意的、太大的、同名的,这三种才是真正会出事的地方。 实测列表页最后显示 4 行:一张 cat、一张 cat、两张 dog。 那两张 dog 指着同一个文件,正是第六节说的那个后果。
八、动手 Demo:上传页:表单、端点与那几个必须防的坑
这个 Demo 从一张表单开始,把上传这件事的每条分支都试一遍:正常的、恶意的、太大的、同名的。 第六节那两张图放在一起看,才知道「加个前缀」不是讲究,是必须。
@bp.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
form = UploadForm()
if form.validate_on_submit():
f = form.file.data
safe = secure_filename(f.filename)
name = uuid.uuid4().hex[:8] + "_" + (safe or "image.png")
f.save(os.path.join(current_app.config["UPLOAD_FOLDER"], name))
db.session.add(UserImage(filename=name, user_id=current_user.id))
db.session.commit()
return redirect(url_for("images.index"))
return render_template("images/upload.html", form=form)
实跑输出:
--------------------------------------------------------------------
上传页:表单、端点与那几个必须防的坑
--------------------------------------------------------------------
① 表单:三个属性一个都不能少
--------------------------------------------------------------------
[代码原样]
| <form method="post" enctype="multipart/form-data">
| <input type="file" name="file">
| <button>上传</button>
| </form>
enctype 必须是 multipart/form-data,否则浏览器只发文件名,不发内容。
没有 enctype 的话,服务端收到的文件名在、文件是空的——这个症状很容易被当成「保存那一步写错了」。
② 端点三步:洗名、去重、存盘
--------------------------------------------------------------------
[代码原样]
| safe = secure_filename(f.filename) or ("image." + ext)
| name = uuid.uuid4().hex[:8] + "_" + safe
| f.save(os.path.join(UP, name))
洗完是空字符串时要有后备名,否则文件会叫 ".png"。
! 三件事缺一不可:洗名防穿越、加前缀防重名、存盘到配置里那个目录。顺序也不能反——先洗再拼。
③ 正常上传一张
--------------------------------------------------------------------
POST /images/upload -> 302,Location=/images/
上传目录里的文件数 0 -> 1:['6178c4f3_cat.png']
库里记录数 -> 1,文件名 -> ['6178c4f3_cat.png']
④ 换个扩展名再传
--------------------------------------------------------------------
传 evil.exe -> 200,
页面提示 -> ['<p class="success">上传成功</p><p class="danger">只收 png 与 jpg</p>']
上传目录里的文件数 -> 1(没变)
! 扩展名白名单要在服务端判。前端的 accept 属性只是给文件选择框用的过滤,改了就能绕过。
⑤ 超过大小上限
--------------------------------------------------------------------
传一张 3MB 的(上限 2MB)-> 413
! 413 是「请求体太大」,在进入视图之前就被拦了——所以端点里那句「校验扩展名」根本没跑到。
! 这个上限就是配置里那个 MAX_CONTENT_LENGTH。
它是防线,不是建议:不设的话,一次大文件上传就能把内存吃光。
⑥ 同名文件会不会互相覆盖
--------------------------------------------------------------------
同一个 cat.png 再传一次 -> 302
目录里现在有 2 个文件:['6178c4f3_cat.png', '6cb0587f_cat.png']
! 两个文件名不同,因为前面拼了 8 位十六进制。
没有这个前缀,第二张会把第一张覆盖掉,而数据库里两条记录指着同一个文件。
去掉前缀之后再传两次同一个 dog.png,目录里叫 dog.png 的文件有 1 个
! 一次覆盖一次,剩一个。
这就是「重名」在真实场景里的样子:用户上传的同名照片,在服务器上只能留一张。
⑦ 列表页把新图片显示出来
--------------------------------------------------------------------
GET /images/ -> 200
<tr><td>1</td><td>6178c4f3_cat.png</td>
<tr><td>2</td><td>6cb0587f_cat.png</td>
<tr><td>3</td><td>dog.png</td>
<tr><td>4</td><td>dog.png</td>
! 缩略图走的是 /images/file/xxx,也就是上一篇那个 send_from_directory 端点。
上传与取图是两个端点,各管一头。
第三节的 0 -> 1 是两次计数:传之前和传之后。 第四节返回 200 而不是 302,说明它没进「成功」那条路。 第六节两组对比:有前缀两个文件,没前缀一个文件。
30 秒实验
- 把 enctype 删掉再传一次,看服务端收到的 filename 和文件大小各是什么。
- 把 uuid 前缀去掉,连传三次同一个文件名,看目录里剩几个、库里几行。
- 想一想:为什么 413 返回时,端点里写的扩展名校验没有执行?
常见误区
- 漏了 enctype:文件名在、内容为空,很容易误判成保存那一步的问题。
- 只靠前端 accept 过滤:改一下请求就能传任何扩展名。
- 文件名直接落盘:同名覆盖,两条记录指一个文件。
- 不设 MAX_CONTENT_LENGTH:一次大文件上传就能吃光内存。
自测三问(点开对答案)
1. enctype 漏了会发生什么? —— 浏览器只发文件名不发内容,服务端收到的文件是 0 字节。 2. 为什么文件名前面要加一段 uuid? —— 防重名覆盖。不加的话同名照片会互相覆盖,而数据库里两条记录指着同一个文件。 3. 413 是谁返回的? —— Flask 在处理请求体时按 MAX_CONTENT_LENGTH 拦下来的,还没进到视图函数。第 7 章到此结束。下一篇开始接物件侦测:模型、检测程序库、把结果画到图上。
- ☐ (本篇完,下一篇 8.1 UserImageTags 模型与检测程序库)