308 lines
8.9 KiB
Go
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"})
|
|
}
|