实战三:文件上传服务(限大小/限类型/存盘路径安全/下载)

文件上传是「请求体边界 + 文件系统安全」的集合题:限大小、验类型、防路径穿越、可控下载。本章基于 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、鉴权、部署与三个实战都已覆盖,深入各库边界时请以官方文档为准。

笔记加载中…