🧪 第7章 建立图片上传页面
📄 一句话概括

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 秒实验

  1. 把 enctype 删掉再传一次,看服务端收到的 filename 和文件大小各是什么。
  2. 把 uuid 前缀去掉,连传三次同一个文件名,看目录里剩几个、库里几行。
  3. 想一想:为什么 413 返回时,端点里写的扩展名校验没有执行?

常见误区

  1. 漏了 enctype:文件名在、内容为空,很容易误判成保存那一步的问题。
  2. 只靠前端 accept 过滤:改一下请求就能传任何扩展名。
  3. 文件名直接落盘:同名覆盖,两条记录指一个文件。
  4. 不设 MAX_CONTENT_LENGTH:一次大文件上传就能吃光内存。
自测三问(点开对答案) 1. enctype 漏了会发生什么? —— 浏览器只发文件名不发内容,服务端收到的文件是 0 字节。 2. 为什么文件名前面要加一段 uuid? —— 防重名覆盖。不加的话同名照片会互相覆盖,而数据库里两条记录指着同一个文件。 3. 413 是谁返回的? —— Flask 在处理请求体时按 MAX_CONTENT_LENGTH 拦下来的,还没进到视图函数。

第 7 章到此结束。下一篇开始接物件侦测:模型、检测程序库、把结果画到图上。

相关笔记