下载大文件、断点续传、中文文件名、c.File 的路径从哪来——这些在 Gin 里都归 c.File 与 FileAttachment 管。这篇用真实的 Range 请求和 200 MiB 文件测它们的行为,并重点验证一个常见的安全坑:把用户给的路径交给 c.File。
context.go 的 File(Gin v1.12.0):
1280 | func (c *Context) File(filepath string) {
1281 | http.ServeFile(c.Writer, c.Request, filepath)
1282 | }
context.go 的 FileAttachment(Gin v1.12.0):
1303 | func (c *Context) FileAttachment(filepath, filename string) {
1304 | if isASCII(filename) {
1305 | c.Writer.Header().Set("Content-Disposition", `attachment; filename="`+escapeQuotes(filename)+`"`)
1306 | } else {
1307 | c.Writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(filename))
1308 | }
1309 | http.ServeFile(c.Writer, c.Request, filepath)
1310 | }c.File 只有一行 http.ServeFile,所以它继承了标准库的全部能力:Range、If-Range、If-Modified-Since、按扩展名与嗅探确定 Content-Type、目录请求的重定向。FileAttachment 只是多设了一个 Content-Disposition。
无 Range → 200 长度 100 Accept-Ranges="bytes" Content-Length=100 Last-Modified 有=true
Range: "bytes=0-9" → 206 Content-Range="bytes 0-9/100" Content-Type="text/plain; charset=utf-8" 正文长度 10 "0123456789"
Range: "bytes=95-" → 206 Content-Range="bytes 95-99/100" Content-Type="text/plain; charset=utf-8" 正文长度 5 "56789"
Range: "bytes=-5" → 206 Content-Range="bytes 95-99/100" Content-Type="text/plain; charset=utf-8" 正文长度 5 "56789"
Range: "bytes=0-0" → 206 Content-Range="bytes 0-0/100" Content-Type="text/plain; charset=utf-8" 正文长度 1 "0"
Range: "bytes=50-1000" → 206 Content-Range="bytes 50-99/100" Content-Type="text/plain; charset=utf-8" 正文长度 50 "012345678901234567890
Range: "bytes=0-4,10-14" → 206 Content-Range="" Content-Type="multipart/byteranges; boundary=8521331b61e76fcdef9df26172a6e578bc00b2aa0df074666b3c
Range: "bytes=200-300" → 416 Content-Range="bytes */100" Content-Type="text/plain; charset=utf-8" 正文长度 33 "invalid range: failed
Range: "bytes=5-2" → 416 Content-Range="" Content-Type="text/plain; charset=utf-8" 正文长度 14 "invalid range\n"
Range: "bytes=abc" → 416 Content-Range="" Content-Type="text/plain; charset=utf-8" 正文长度 14 "invalid range\n"
Range: "items=0-4" → 416 Content-Range="" Content-Type="text/plain; charset=utf-8" 正文长度 14 "invalid range\n"
Range: "bytes=0-9, 10-19, 20-29" → 206 Content-Range="" Content-Type="multipart/byteranges; boundary=c81baa55aabe3b4167edd0177fdfb4acde6305619aacad39bba
对照 c.Data + Range: bytes=0-9 → 200 Accept-Ranges="" 正文长度 100(整个文件)
HEAD + Range → 206 Content-Range="bytes 0-9/100" Content-Length=10单段 Range 返回 206 与 Content-Range;bytes=95-、bytes=-5 都表示末尾,超出文件末尾的上界被截断;多段返回 multipart/byteranges;起点超出文件或语法不对返回 416(起点越界时带 Content-Range: bytes */总长度)。HEAD 加 Range 同样返回 206。c.Data / c.String 没有这些,Range 请求总是返回整个内容,也没有 Accept-Ranges。
If-Range=Last-Modified(匹配)→ 206 长度 10
If-Range=更早的日期(文件“变了”)→ 200 长度 100(返回整个文件)
If-Range=一个 ETag(服务端没有 ETag)→ 200 长度 100If-Range 用 Last-Modified 做条件:匹配就返回部分内容,文件变过就返回完整文件,避免拼出损坏的文件。服务端没有 ETag,所以带 ETag 的 If-Range 总是退化成 200 整个文件。
filename="report.txt" → Content-Disposition: attachment; filename="report.txt"
filename="my report.txt" → Content-Disposition: attachment; filename="my report.txt"
filename="a\"b.txt" → Content-Disposition: attachment; filename="a\"b.txt"
filename="a\\b.txt" → Content-Disposition: attachment; filename="a\\b.txt"
filename="报告.txt" → Content-Disposition: attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A.txt
filename="中 文.txt" → Content-Disposition: attachment; filename*=UTF-8''%E4%B8%AD+%E6%96%87.txt
filename="résumé.txt" → Content-Disposition: attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.txt纯 ASCII 文件名用 filename="…"(转义引号与反斜杠);含非 ASCII 用 RFC 5987 的 filename*=UTF-8''…,但编码用的是 url.QueryEscape,空格变成 + 而不是 %20,带空格的中文文件名在浏览器里可能显示成加号。需要时自己拼 Content-Disposition。
GET /q?f=a.txt → 200 "012345678901234567890123…"
GET /q?f=../secret.txt → 200 "OUTSIDE-THE-ROOT"
GET /q?f=..%2fsecret.txt → 200 "OUTSIDE-THE-ROOT"
GET /p/a.txt → 200 "012345678901234567890123…"
GET /p/../secret.txt → 400 "invalid URL path"
GET /p/..%2fsecret.txt → 400 "invalid URL path"
GET /safe?f=a.txt → 200 "012345678901234567890123…"
GET /safe?f=../secret.txt → 404 "404 page not found"ServeFile 只检查请求 URL 的路径里有没有 ..,并不检查你传给它的文件名。把查询参数拼进路径,../ 就能读到根目录之外的文件。写法是 filepath.Join(root, filepath.Clean("/"+f)),让 Clean 先把 .. 吃掉;更好的做法是用 ID 查文件,不接受路径。
/file → 200 收到 200 MiB Content-Length=209715200 X-Extra="";进程累计分配增加 0.7 MiB
/data → 200 收到 200 MiB Content-Length=209715200 X-Extra="";进程累计分配增加 200.1 MiB
/reader → 200 收到 200 MiB Content-Length=209715200 X-Extra="yes";进程累计分配增加 0.0 MiBc.File 和 DataFromReader 都是流式发送,累计分配几乎为零;先 ReadFile 再 c.Data 会把整个文件读进内存,并发下载时内存与文件大小成正比。DataFromReader 要自己提供 Content-Length,也不支持 Range。