package main import ( "archive/zip" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" ) // ----------------------------------------------------------------------------- // 批量下载: POST /download-zip (body: {"files":[...]} 或 query ?files=a&files=b) // 流式 archive/zip 输出, 不写临时文件. 缺失/路径非法/超量统一 4xx 兜底. // ----------------------------------------------------------------------------- const ( batchMaxFiles = 100 batchNameLimit = 200 ) type batchRequest struct { Files []string `json:"files"` } func batchDownloadHandler(c *gin.Context) { // 1. 收集 filenames: body JSON 优先, fallback 到 query ?files= var req batchRequest var names []string if c.Request.ContentLength > 0 && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { if err := c.ShouldBindJSON(&req); err == nil && len(req.Files) > 0 { names = req.Files } } if names == nil { names = c.QueryArray("files") } if len(names) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no files specified"}) audit(c, "batch_download", gin.H{"result": "no_files"}) return } if len(names) > batchMaxFiles { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("too many files (max %d)", batchMaxFiles), }) audit(c, "batch_download", gin.H{ "count": len(names), "result": "too_many", }) return } // 2. 校验每个文件名, 检查存在性 type entry struct { path string size int64 } var packed []entry miss := []string{} // 非 nil, JSON 输出 [] 而非 null for _, raw := range names { if len(raw) > batchNameLimit { c.JSON(http.StatusBadRequest, gin.H{"error": "filename too long (max 200 bytes)"}) audit(c, "batch_download", gin.H{ "count": len(names), "result": "too_long", }) return } name, ok := validateFilename(raw) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename: " + raw}) audit(c, "batch_download", gin.H{ "file": raw, "result": "invalid_filename", }) return } p := filepath.Join(*uploadDir, name) info, err := os.Stat(p) if os.IsNotExist(err) || (err == nil && info.IsDir()) { miss = append(miss, name) continue } if err != nil { log.Printf("[batch_download] stat failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) audit(c, "batch_download", gin.H{ "file": name, "result": "stat_failed", }) return } packed = append(packed, entry{path: p, size: info.Size()}) } if len(packed) == 0 { c.JSON(http.StatusNotFound, gin.H{ "error": "no files available", "miss": miss, }) audit(c, "batch_download", gin.H{ "count": len(names), "packed": 0, "miss": miss, "result": "all_miss", }) return } // 3. 流式 zip. WriteHeader 必须先于 body 写出, 之后 io.Copy 直写 c.Writer. zipName := "files-" + time.Now().Format("20060102-150405") + ".zip" c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", `attachment; filename="`+zipName+`"`) c.Header("X-Content-Type-Options", "nosniff") c.Writer.WriteHeader(http.StatusOK) zw := zip.NewWriter(c.Writer) now := time.Now() for _, e := range packed { fw, err := zw.Create(filepath.Base(e.path)) if err != nil { log.Printf("[batch_download] zip create failed: %v", err) continue } f, err := os.Open(e.path) if err != nil { log.Printf("[batch_download] open failed: %v", err) continue } if _, err := io.Copy(fw, f); err != nil { log.Printf("[batch_download] copy failed: %v", err) } f.Close() _ = os.Chtimes(e.path, now, now) // 下载即续期, 与单文件一致 } if err := zw.Close(); err != nil { log.Printf("[batch_download] zip close failed: %v", err) } audit(c, "batch_download", gin.H{ "count": len(names), "packed": len(packed), "miss": miss, "result": "ok", }) } // ----------------------------------------------------------------------------- // 批量删除: POST /files-delete (body: {"files":[...]}) // 破坏性操作, 仅接受 JSON body, 不做 query fallback (不接受 URL 参数驱动). // 两阶段: 先全量 stat (缺失/目录 → miss), 再逐个 os.Remove, 失败继续并计入 failed. // ----------------------------------------------------------------------------- func batchDeleteHandler(c *gin.Context) { // 1. 仅接受 JSON body 收集 filenames, 不做 query fallback (破坏性操作) var req batchRequest jsonBody := c.Request.ContentLength > 0 && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") if !jsonBody { c.JSON(http.StatusBadRequest, gin.H{"error": "no files specified"}) audit(c, "batch_delete", gin.H{"result": "no_files"}) return } if err := c.ShouldBindJSON(&req); err != nil || len(req.Files) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no files specified"}) audit(c, "batch_delete", gin.H{"result": "no_files"}) return } names := req.Files if len(names) > batchMaxFiles { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("too many files (max %d)", batchMaxFiles), }) audit(c, "batch_delete", gin.H{ "count": len(names), "result": "too_many", }) return } // 2. 校验每个文件名, 第一遍 stat (缺失/目录 → miss; 其他错误 → 500) type entry struct { path string size int64 } var targets []entry miss := []string{} // 非 nil, JSON 输出 [] 而非 null for _, raw := range names { if len(raw) > batchNameLimit { c.JSON(http.StatusBadRequest, gin.H{"error": "filename too long (max 200 bytes)"}) audit(c, "batch_delete", gin.H{ "count": len(names), "result": "too_long", }) return } name, ok := validateFilename(raw) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename: " + raw}) audit(c, "batch_delete", gin.H{ "file": raw, "result": "invalid_filename", }) return } p := filepath.Join(*uploadDir, name) info, err := os.Stat(p) if os.IsNotExist(err) || (err == nil && info.IsDir()) { miss = append(miss, name) continue } if err != nil { log.Printf("[batch_delete] stat failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) audit(c, "batch_delete", gin.H{ "file": name, "result": "stat_failed", }) return } targets = append(targets, entry{path: p, size: info.Size()}) } // 3. 逐个 os.Remove; 失败 log + 计入 failed 并继续; 成功扣配额 failed := []string{} // 非 nil, JSON 输出 [] 而非 null deleted := 0 for _, t := range targets { if err := os.Remove(t.path); err != nil { log.Printf("[batch_delete] remove failed: %v", err) failed = append(failed, filepath.Base(t.path)) continue } quotaSub(t.size) deleted++ } if deleted == 0 { c.JSON(http.StatusNotFound, gin.H{ "error": "no files deleted", "miss": miss, "failed": failed, }) audit(c, "batch_delete", gin.H{ "count": len(names), "deleted": deleted, "miss": miss, "failed": failed, "result": "all_miss", }) return } c.JSON(http.StatusOK, gin.H{ "deleted": deleted, "miss": miss, "failed": failed, }) audit(c, "batch_delete", gin.H{ "count": len(names), "deleted": deleted, "miss": miss, "failed": failed, "result": "ok", }) }