package main import ( "archive/zip" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" ) // ----------------------------------------------------------------------------- // 路由处理器 // ----------------------------------------------------------------------------- type fileResponse struct { Filename string `json:"filename"` URL string `json:"url"` Size int64 `json:"size"` UploadedAt time.Time `json:"uploaded_at"` ExpiresAt time.Time `json:"expires_at"` } // validateFilename 从 URL/JSON 参数提取并校验 filename. // 返回 (sanitized, true) 表示安全可访问, 否则返回 ("", false). // 规则与 downloadHandler 原内联逻辑一致: filepath.Base + 拒绝 "" / "." / ".." // + 拒绝含 "/" "\" NUL 的名字 (后者同时兜住 Windows 路径). func validateFilename(raw string) (string, bool) { name := filepath.Base(raw) if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") { return "", false } return name, true } func uploadHandler(c *gin.Context) { f, err := c.FormFile(fieldName) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing form field 'file'"}) audit(c, "upload", gin.H{"file": "", "result": "missing_form_field"}) return } // 早 reject: 防止 NUL 字节 + 超长文件名打爆 Stat/Save if strings.ContainsRune(f.Filename, 0) { c.JSON(http.StatusBadRequest, gin.H{"error": "filename contains null byte"}) audit(c, "upload", gin.H{"file": f.Filename, "result": "null_byte"}) return } // Linux/macOS 路径分量 NAME_MAX = 255 字节 if len(f.Filename) > 200 { c.JSON(http.StatusBadRequest, gin.H{"error": "filename too long (max 200 bytes)"}) audit(c, "upload", gin.H{"file": f.Filename, "result": "filename_too_long"}) return } if err := os.MkdirAll(*uploadDir, 0o755); err != nil { log.Printf("[upload] mkdir failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) audit(c, "upload", gin.H{"file": f.Filename, "result": "mkdir_failed"}) return } // 配额检查 (单文件能塞下 + 不会超总配额) if *quota > 0 && f.Size > quotaRemaining() { used, cap_ := quotaSnapshot() c.JSON(http.StatusInsufficientStorage, gin.H{ "error": fmt.Sprintf("quota exceeded: %s used / %s cap, file size %s", formatSize(used), formatSize(cap_), formatSize(f.Size)), }) audit(c, "upload", gin.H{ "file": f.Filename, "size": f.Size, "result": "quota_exceeded", }) return } name := uniqueFilename(*uploadDir, f.Filename) dst := filepath.Join(*uploadDir, name) if err := c.SaveUploadedFile(f, dst); err != nil { log.Printf("[upload] save failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) audit(c, "upload", gin.H{"file": f.Filename, "result": "save_failed"}) return } // 实际写入的字节数可能与 f.Size 略有差异 (SaveUploadedFile 内部处理), 用 stat 拿真实值 var written int64 = f.Size if info, statErr := os.Stat(dst); statErr == nil { written = info.Size() } // 原子预留配额, 兜住并发下 pre-save 检查放行的多个请求 if !quotaReserve(written) { _ = os.Remove(dst) used, cap_ := quotaSnapshot() c.JSON(http.StatusInsufficientStorage, gin.H{ "error": fmt.Sprintf("quota exceeded: %s used / %s cap, file size %s", formatSize(used), formatSize(cap_), formatSize(written)), }) audit(c, "upload", gin.H{ "file": name, "size": written, "result": "quota_exceeded", }) return } now := time.Now() _ = os.Chtimes(dst, now, now) c.JSON(http.StatusOK, fileResponse{ Filename: name, URL: "/download/" + name, Size: f.Size, UploadedAt: now, ExpiresAt: now.Add(*fileTTL), }) audit(c, "upload", gin.H{ "file": name, // 落盘后的名字 (可能加了 (1) 后缀) "orig": f.Filename, "size": f.Size, "result": "ok", }) } // uniqueFilename 把 original 清洗后, 在 dir 中找一个不存在的名字. // 规则: 原名 → 原名 (1) → 原名 (2) → ... // 例: report.pdf -> report.pdf // // report.pdf (1) -> report.pdf (1) // .gitignore -> .gitignore // .gitignore (1) -> .gitignore (1) func uniqueFilename(dir, original string) string { // 1. 剥离路径组件, 防穿越 base := filepath.Base(original) if base == "" || base == "." || base == ".." { base = "file" } // 2. 拆分 stem 和 ext, 处理 .gitignore 这类隐藏文件 ext := filepath.Ext(base) stem := strings.TrimSuffix(base, ext) if stem == "" { // 整个名字都是扩展名 (如 .gitignore), 把整个当 stem ext = "" stem = base } // 3. 依次尝试 base, base (1), base (2), ... // 用 O_CREATE|O_EXCL 原子占位, 解决 TOCTOU 竞态 (20 路并发同名上传必须各自拿到不同名字) // 占位的 0 字节 placeholder 会在 SaveUploadedFile 时被覆盖 for i := range 10000 { var candidate string if i == 0 { candidate = base } else { candidate = fmt.Sprintf("%s (%d)%s", stem, i, ext) } p := filepath.Join(dir, candidate) f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err == nil { f.Close() return candidate } if !os.IsExist(err) { // 非 "已存在" 错误 (权限/磁盘满等), 兜底用时间戳 log.Printf("[uniqueFilename] open err=%v, fallback to ts", err) return fmt.Sprintf("%s (%d)%s", stem, time.Now().UnixNano(), ext) } } return fmt.Sprintf("%s (%d)%s", stem, time.Now().UnixNano(), ext) } func downloadHandler(c *gin.Context) { raw := c.Param("filename") name, ok := validateFilename(raw) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) audit(c, "download", gin.H{"file": raw, "result": "invalid_filename"}) return } p := filepath.Join(*uploadDir, name) info, err := os.Stat(p) if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "file not found or expired"}) audit(c, "download", gin.H{"file": name, "result": "not_found"}) return } if err != nil { log.Printf("[download] stat failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) audit(c, "download", gin.H{"file": name, "result": "stat_failed"}) return } if info.IsDir() { c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"}) audit(c, "download", gin.H{"file": name, "result": "is_dir"}) return } // 强制下载 + 防 XSS (浏览器不会按扩展名/Content-Type 渲染) c.Header("Content-Disposition", `attachment; filename="`+name+`"`) c.Header("X-Content-Type-Options", "nosniff") // 下载即续期 now := time.Now() _ = os.Chtimes(p, now, now) c.File(p) audit(c, "download", gin.H{ "file": name, "size": info.Size(), "result": "ok", }) } type fileInfo struct { Filename string `json:"filename"` URL string `json:"url"` Size int64 `json:"size"` ModTime time.Time `json:"mod_time"` ExpiresAt time.Time `json:"expires_at"` } func listHandler(c *gin.Context) { entries, err := os.ReadDir(*uploadDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } files := make([]fileInfo, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } info, err := e.Info() if err != nil { continue } files = append(files, fileInfo{ Filename: e.Name(), URL: "/download/" + e.Name(), Size: info.Size(), ModTime: info.ModTime(), ExpiresAt: info.ModTime().Add(*fileTTL), }) } c.JSON(http.StatusOK, gin.H{ "count": len(files), "now": time.Now(), "ttl": fileTTL.String(), "quota": func() gin.H { used, cap_ := quotaSnapshot() return gin.H{ "used": used, "cap": cap_, "used_str": formatSize(used), "cap_str": formatSize(cap_), } }(), "files": files, }) } func deleteHandler(c *gin.Context) { raw := c.Param("filename") name, ok := validateFilename(raw) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) audit(c, "delete", gin.H{"file": raw, "result": "invalid_filename"}) return } p := filepath.Join(*uploadDir, name) info, err := os.Stat(p) if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "file not found or already deleted"}) audit(c, "delete", gin.H{"file": name, "result": "not_found"}) return } if err != nil { log.Printf("[delete] stat failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) audit(c, "delete", gin.H{"file": name, "result": "stat_failed"}) return } if info.IsDir() { c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"}) audit(c, "delete", gin.H{"file": name, "result": "is_dir"}) return } if err := os.Remove(p); err != nil { log.Printf("[delete] remove failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) audit(c, "delete", gin.H{"file": name, "result": "remove_failed"}) return } quotaSub(info.Size()) c.JSON(http.StatusOK, gin.H{"deleted": name}) audit(c, "delete", gin.H{"file": name, "size": info.Size(), "result": "ok"}) } // ----------------------------------------------------------------------------- // 批量下载: 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", }) }