实战三:文件上传服务(限大小/限类型/存盘路径安全/下载)
文件上传是「请求体边界 + 文件系统安全」的集合题:限大小、验类型、防路径穿越、可控下载。本章基于 Gin 的 multipart 支持实现,HTTP 语义以 net/http 官方文档为准。
1. 路由与内存缓冲
r := gin.Default()
r.MaxMultipartMemory = 8 << 20 // 8 MiB:超出部分先落临时磁盘
api := r.Group("/api/v1")
api.POST("/files", AuthRequired(), h.Upload)
api.GET("/files/:name", h.Download)
2. 限大小:双保险
MaxMultipartMemory 只是内存缓冲阈值而非总大小上限,总上限用 http.MaxBytesReader 包住请求体(multipart 解析读的也是这个 body)实现,并按需 api.Use(limitBody(10 << 20)) 挂载:
func limitBody(max int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, max)
c.Next()
}
}
超出后 c.FormFile 返回过大类错误,按 413 响应:http.StatusRequestEntityTooLarge。
3. 类型与扩展名双重校验
var allowedExt = map[string]bool{".png": true, ".jpg": true, ".jpeg": true}
func (h *FileHandler) Upload(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil { fail(c, 400, "NO_FILE", "缺少 file 字段"); return }
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedExt[ext] { fail(c, 400, "BAD_TYPE", "仅支持图片格式"); return }
src, err := file.Open()
if err != nil { fail(c, 500, "IO_ERROR", "读取失败"); return }
defer src.Close()
head := make([]byte, 512)
src.Read(head) // DetectContentType 读取前 512 字节
if ct := http.DetectContentType(head); !strings.HasPrefix(ct, "image/") {
fail(c, 400, "BAD_TYPE", "文件内容不是图片")
return
}
}
4. 存盘路径安全
randName := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomHex(8), ext)
dst := filepath.Join(h.saveDir, randName) // saveDir 是服务端固定目录
if err := c.SaveUploadedFile(file, dst); err != nil {
fail(c, 500, "SAVE_FAIL", "保存失败")
return
}
- 不能用
file.Filename直接拼路径(可能含../实现穿越)。 filepath.Base()只作辅助,最终名字必须服务端生成。- 存盘目录放在 Web 可执行区之外,避免上传文件被当作脚本直接访问。
5. 下载接口
func (h *FileHandler) Download(c *gin.Context) {
name := filepath.Base(c.Param("name")) // 只取基名
full := filepath.Join(h.saveDir, name)
if !strings.HasPrefix(full, h.saveDir) { // 二次防御
fail(c, 400, "BAD_NAME", "非法文件名")
return
}
c.FileAttachment(full, name) // 附件下载,带 Content-Disposition
}
c.File 内联展示(如图片预览),c.FileAttachment 附件下载,按需二选一。
6. 测试要点
- 超大文件 → 413;扩展名伪造的非图片 → 400。
- 文件名带
../../→ 仍落在 saveDir 内;下载不存在 → 404,成功 → 200 且响应头带文件名。
注意点
- 有 CDN/对象存储后,本服务可只负责「收文件转存」,别把本机盘当长期方案。
- 缩略图、病毒扫描属更深的信任链,生产按合规要求补充。
小结
上传的每个环节都在回答「谁能传什么、存到哪、谁能取」:限大小防拖垮、验类型防伪装、服务端命名防穿越、下载接口防越权。至此 GORM、Redis、鉴权、部署与三个实战都已覆盖,深入各库边界时请以官方文档为准。