package main import ( "fmt" "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"}) }