Files
2026-08-28 10:54:08 +08:00

308 lines
8.9 KiB
Go

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"})
}