一次上传好几个文件,该用 FormFile 还是 MultipartForm?怎么绑定到结构体?客户端给的文件名能不能直接用来保存?SaveUploadedFile 会对目录做什么?这篇用真实的 multipart 请求把这些问题一一实测。
请求里的顺序:b.txt、a.png、c.bin(都叫 files),另有 other 字段的 o.txt
FormFile(files) → b.txt 3 字节
File["files"] = b.txt(3 字节,text/plain) a.png(10 字节,image/png) c.bin(0 字节,application/octet-stream);表单里共有的文件字段名:2 个;Value=map[title:[t]]
--- 没有这个字段时:
http: no such fileFormFile(name) 只能拿到该字段的第一个文件;要全部文件用 MultipartForm() 再取 File[name],顺序与请求一致。每个 FileHeader 带文件名、大小和原始的 part 头(包含客户端声明的 Content-Type,不可信)。
全部给出 → Title="t" Avatar=me.png Files=[1.txt 2.txt] err=<nil>
缺 avatar → Title="t" Avatar=nil Files=[1.txt] err=Key: 'Up.Avatar' Error:Field validation for 'Avatar' failed on the 'required' tag
缺 title → Title="" Avatar=me.png Files=[] err=Key: 'Up.Title' Error:Field validation for 'Title' failed on the 'required' tag
files 只有一个 → Title="t" Avatar=me.png Files=[only.txt] err=<nil>表单绑定认识 *multipart.FileHeader 和 []*multipart.FileHeader 两种字段类型,和普通字段、binding 校验标签一起工作。只上传一个文件时切片字段长度为 1,不会变成 nil。
客户端发送 filename="report.pdf" → 服务端读到 "report.pdf"(200)
客户端发送 filename="../../etc/passwd" → 服务端读到 "passwd"(200)
客户端发送 filename="/etc/passwd" → 服务端读到 "passwd"(200)
客户端发送 filename="a/b/c.txt" → 服务端读到 "c.txt"(200)
客户端发送 filename="..\\..\\win.ini" → 服务端读到 "..\\..\\win.ini"(200)
客户端发送 filename="C:\\Users\\x\\a.txt" → 服务端读到 "C:\\Users\\x\\a.txt"(200)
客户端发送 filename=".." → 服务端读到 ".."(200)
客户端发送 filename="." → 服务端读到 "."(200)
客户端发送 filename="a\x00b.txt" → 服务端读到 malformed MIME header line: "Content-Disposition: form-data; name=\"f\"; filename=\"a\x00b.txt\""(400)
客户端发送 filename=" spaced name .txt" → 服务端读到 " spaced name .txt"(200)Go 在解析时先对文件名做 filepath.Base,所以 ../../etc/passwd 只剩 passwd。但这是按服务器平台的规则:在 Unix 上反斜杠不是分隔符,..\..\win.ini 原样保留;..、. 也原样保留。保存文件时最稳妥的做法是不用客户端的文件名,自己生成(如 UUID + 白名单扩展名)。
context.go 的 SaveUploadedFile(Gin v1.12.0):
719 | func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string, perm ...fs.FileMode) error {
720 | src, err := file.Open()
721 | if err != nil {
722 | return err
723 | }
724 | defer src.Close()
725 |
726 | var mode os.FileMode = 0o750
727 | if len(perm) > 0 {
728 | mode = perm[0]
729 | }
730 | dir := filepath.Dir(dst)
731 | if err = os.MkdirAll(dir, mode); err != nil {
732 | return err
733 | }
734 | if err = os.Chmod(dir, mode); err != nil {
735 | return err
736 | }
737 |
738 | out, err := os.Create(dst)
739 | if err != nil {
740 | return err
741 | }
742 | defer out.Close()
743 |
744 | _, err = io.Copy(out, src)
745 | return err
746 | }
保存之前 exist 目录权限: drwxr-xr-x
保存之后 exist 目录权限: drwxr-x--- 新建的 new/deep: drwxr-x--- new: drwxr-x--- perm(传了 0700): drwx------
b.txt 内容长度: 7它先 MkdirAll 创建多级目录,再对目标目录 Chmod(默认 0750)。已经存在的目录权限会被悄悄收紧——保存到一个 0755 的共享目录,之后这个目录对其它用户不可读了。需要不同权限时给它传 perm 参数,或者自己创建目录再保存。
999 个文件 → 200 收到 999 个文件
1000 个文件 → 200 收到 1000 个文件
1001 个文件 → 400 multipart: message too large一个 multipart 请求最多 1000 个 part(包括普通字段),超过时 multipart: message too large。这是 Go 标准库的限制,可以用环境变量 GODEBUG=multipartmaxparts=… 调整(本文没有验证调整)。