package main import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "io" "io/fs" "mime" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) const sessionCookie = "ufile_session" var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`) var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`) type App struct { db *gorm.DB filesDir string web fs.FS uploadMu sync.Mutex downloadMu sync.Mutex now func() time.Time } func newApp(dbPath, dataDir string, web fs.FS) (*App, string, error) { filesDir := filepath.Join(dataDir, "files") if err := os.MkdirAll(filesDir, 0o750); err != nil { return nil, "", fmt.Errorf("create data directory: %w", err) } db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) if err != nil { return nil, "", fmt.Errorf("open database: %w", err) } if err := db.AutoMigrate(&User{}, &Session{}, &SharedFile{}); err != nil { return nil, "", fmt.Errorf("migrate database: %w", err) } app := &App{db: db, filesDir: filesDir, web: web, now: time.Now} password, err := app.ensureAdmin() return app, password, err } func (a *App) ensureAdmin() (string, error) { var count int64 if err := a.db.Model(&User{}).Count(&count).Error; err != nil { return "", err } if count != 0 { return "", nil } password, err := randomToken(18) if err != nil { return "", err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } admin := User{ID: 1, Username: "admin", PasswordHash: string(hash), CanManageUsers: true, CanUpload: true, CanAdvancedUpload: true, MustChangePassword: true} if err := a.db.Create(&admin).Error; err != nil { return "", err } return password, nil } func (a *App) router() *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Logger(), gin.Recovery()) r.POST("/api/auth/login", a.login) r.POST("/api/auth/logout", a.auth(false), a.logout) r.GET("/api/auth/me", a.auth(false), a.me) r.PUT("/api/auth/password", a.auth(false), a.changePassword) r.POST("/api/files", a.auth(true), a.uploadFile) r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers) r.POST("/api/users", a.auth(true), a.manageUsers, a.createUser) r.PUT("/api/users/:id", a.auth(true), a.manageUsers, a.updateUser) r.POST("/api/users/:id/reset-password", a.auth(true), a.manageUsers, a.resetPassword) r.DELETE("/api/users/:id", a.auth(true), a.manageUsers, a.deleteUser) r.GET("/uf/:hash", a.downloadFile) r.NoRoute(a.serveSPA) return r } func (a *App) auth(requireChanged bool) gin.HandlerFunc { return func(c *gin.Context) { token, err := c.Cookie(sessionCookie) if err != nil || token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) return } var session Session if err := a.db.Where("token_hash = ? AND expires_at > ?", tokenHash(token), a.now()).First(&session).Error; err != nil { clearSessionCookie(c) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"}) return } var user User if err := a.db.First(&user, session.UserID).Error; err != nil { clearSessionCookie(c) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"}) return } if requireChanged && user.MustChangePassword { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "请先修改初始密码", "code": "PASSWORD_CHANGE_REQUIRED"}) return } c.Set("user", &user) c.Set("session", &session) c.Next() } } func currentUser(c *gin.Context) *User { return c.MustGet("user").(*User) } func (a *App) manageUsers(c *gin.Context) { if !currentUser(c).CanManageUsers { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "没有用户管理权限"}) return } c.Next() } type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } func (a *App) login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式不正确"}) return } var user User if err := a.db.Where("username = ?", req.Username).First(&user).Error; err != nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) return } token, err := randomToken(32) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败"}) return } session := Session{TokenHash: tokenHash(token), UserID: user.ID, ExpiresAt: a.now().Add(30 * 24 * time.Hour)} if err := a.db.Create(&session).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败"}) return } setSessionCookie(c, token, session.ExpiresAt) c.JSON(http.StatusOK, user) } func (a *App) logout(c *gin.Context) { session := c.MustGet("session").(*Session) a.db.Delete(session) clearSessionCookie(c) c.Status(http.StatusNoContent) } func (a *App) me(c *gin.Context) { c.JSON(http.StatusOK, currentUser(c)) } func (a *App) changePassword(c *gin.Context) { var req struct { CurrentPassword string `json:"currentPassword"` NewPassword string `json:"newPassword"` } if err := c.ShouldBindJSON(&req); err != nil || len(req.NewPassword) < 8 || len(req.NewPassword) > 128 { c.JSON(http.StatusBadRequest, gin.H{"error": "新密码长度需要为 8 到 128 位"}) return } user := currentUser(c) if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)) != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码错误"}) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "修改密码失败"}) return } if err := a.db.Model(user).Updates(map[string]any{"password_hash": string(hash), "must_change_password": false}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "修改密码失败"}) return } user.PasswordHash = string(hash) user.MustChangePassword = false c.JSON(http.StatusOK, user) } func setSessionCookie(c *gin.Context, token string, expires time.Time) { http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", Expires: expires, MaxAge: int(time.Until(expires).Seconds()), HttpOnly: true, SameSite: http.SameSiteLaxMode}) } func clearSessionCookie(c *gin.Context) { http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) } func tokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func randomToken(bytes int) (string, error) { b := make([]byte, bytes) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func randomFileHash() (string, error) { const alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ" b := make([]byte, 8) raw := make([]byte, 8) if _, err := rand.Read(raw); err != nil { return "", err } for i := range b { b[i] = alphabet[int(raw[i])%len(alphabet)] } return string(b), nil } func parseRetention(value string, now time.Time) (*time.Time, bool) { var duration time.Duration switch value { case "1h": duration = time.Hour case "8h": duration = 8 * time.Hour case "1d": duration = 24 * time.Hour case "3d": duration = 72 * time.Hour case "forever": return nil, true default: return nil, false } t := now.Add(duration) return &t, true } func parseDownloads(value string) (*int, bool) { if value == "unlimited" { return nil, true } n, err := strconv.Atoi(value) if err != nil || n < 1 || n > 99 { return nil, false } return &n, true } func (a *App) uploadFile(c *gin.Context) { user := currentUser(c) if !user.CanUpload && !user.CanAdvancedUpload { c.JSON(http.StatusForbidden, gin.H{"error": "没有上传权限"}) return } expiresAt, ok := parseRetention(c.PostForm("retention"), a.now()) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的保留时间"}) return } maxDownloads, ok := parseDownloads(c.PostForm("downloads")) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "下载次数需要为 1 到 99"}) return } if (expiresAt == nil || maxDownloads == nil) && !user.CanAdvancedUpload { c.JSON(http.StatusForbidden, gin.H{"error": "永久保留和无限下载需要高级上传权限"}) return } pickupCode := c.PostForm("pickupCode") if pickupCode != "" && !pickupCodePattern.MatchString(pickupCode) { c.JSON(http.StatusBadRequest, gin.H{"error": "取件码必须是 6 位数字"}) return } header, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"}) return } a.uploadMu.Lock() defer a.uploadMu.Unlock() if !user.CanAdvancedUpload { var count int64 if err := a.db.Model(&SharedFile{}).Where("owner_id = ? AND (expires_at IS NULL OR expires_at > ?)", user.ID, a.now()).Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "检查上传限额失败"}) return } if count >= 10 { c.JSON(http.StatusForbidden, gin.H{"error": "普通上传用户最多保留 10 个有效文件"}) return } } var codeHash string if pickupCode != "" { hash, err := bcrypt.GenerateFromPassword([]byte(pickupCode), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "处理取件码失败"}) return } codeHash = string(hash) } file := SharedFile{OriginalName: filepath.Base(header.Filename), MimeType: header.Header.Get("Content-Type"), OwnerID: user.ID, ExpiresAt: expiresAt, MaxDownloads: maxDownloads, PickupCodeHash: codeHash} if file.MimeType == "" { file.MimeType = "application/octet-stream" } for attempts := 0; attempts < 10; attempts++ { file.ID = 0 file.Hash, err = randomFileHash() if err != nil { break } err = a.db.Create(&file).Error if err == nil { break } } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件记录失败"}) return } src, err := header.Open() if err != nil { a.db.Delete(&file) c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败"}) return } defer src.Close() temp, err := os.CreateTemp(a.filesDir, ".upload-*") if err != nil { a.db.Delete(&file) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) return } tempName := temp.Name() defer os.Remove(tempName) size, copyErr := io.Copy(temp, src) closeErr := temp.Close() finalPath := filepath.Join(a.filesDir, file.Hash) if copyErr != nil || closeErr != nil || os.Rename(tempName, finalPath) != nil { a.db.Delete(&file) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) return } file.Size = size file.Ready = true if err := a.db.Model(&file).Updates(map[string]any{"size": size, "ready": true}).Error; err != nil { os.Remove(finalPath) a.db.Delete(&file) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) return } c.JSON(http.StatusCreated, presentFile(file)) } func (a *App) downloadFile(c *gin.Context) { var file SharedFile if err := a.db.Where("hash = ? AND ready = ?", c.Param("hash"), true).First(&file).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"}) return } if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) { a.removeFile(file) c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"}) return } if file.PickupCodeHash != "" && bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(c.Query("code"))) != nil { c.JSON(http.StatusForbidden, gin.H{"error": "取件码错误"}) return } handle, err := os.Open(filepath.Join(a.filesDir, file.Hash)) if err != nil { a.db.Delete(&file) c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"}) return } defer handle.Close() removeAfter := false if file.MaxDownloads != nil { allowed, exhausted, err := a.consumeDownload(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"}) return } if !allowed { handle.Close() a.removeFile(file) c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"}) return } removeAfter = exhausted } if removeAfter { defer os.Remove(filepath.Join(a.filesDir, file.Hash)) } c.Header("Content-Type", file.MimeType) c.Header("Content-Length", strconv.FormatInt(file.Size, 10)) if c.Query("preview") != "true" { c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": file.OriginalName})) } else { c.Header("Content-Disposition", "inline") c.Header("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'") } c.Header("X-Content-Type-Options", "nosniff") c.Status(http.StatusOK) _, _ = io.Copy(c.Writer, handle) } func (a *App) consumeDownload(file SharedFile) (allowed, exhausted bool, err error) { a.downloadMu.Lock() defer a.downloadMu.Unlock() result := a.db.Model(&SharedFile{}).Where("id = ? AND download_count < ?", file.ID, *file.MaxDownloads).UpdateColumn("download_count", gorm.Expr("download_count + 1")) if result.Error != nil { return false, false, result.Error } if result.RowsAffected == 0 { return false, false, nil } var latest SharedFile if err := a.db.First(&latest, file.ID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return true, false, nil } return false, false, err } if latest.DownloadCount < *file.MaxDownloads { return true, false, nil } if err := a.db.Where("id = ? AND download_count >= ?", file.ID, *file.MaxDownloads).Delete(&SharedFile{}).Error; err != nil { return false, false, err } return true, true, nil } func (a *App) removeFile(file SharedFile) { a.db.Delete(&file) _ = os.Remove(filepath.Join(a.filesDir, file.Hash)) } func (a *App) cleanup() { var files []SharedFile now := a.now() a.db.Where("(expires_at IS NOT NULL AND expires_at <= ?) OR (ready = ? AND created_at < ?)", now, false, now.Add(-time.Hour)).Find(&files) for _, file := range files { a.removeFile(file) } a.db.Where("expires_at <= ?", now).Delete(&Session{}) a.cleanupStorageOrphans(now) } func (a *App) cleanupStorageOrphans(now time.Time) { entries, err := os.ReadDir(a.filesDir) if err != nil { return } for _, entry := range entries { if entry.IsDir() { continue } if strings.HasPrefix(entry.Name(), ".upload-") { if info, err := entry.Info(); err == nil && info.ModTime().Before(now.Add(-time.Hour)) { _ = os.Remove(filepath.Join(a.filesDir, entry.Name())) } continue } if len(entry.Name()) != 8 { continue } var count int64 if err := a.db.Model(&SharedFile{}).Where("hash = ?", entry.Name()).Count(&count).Error; err == nil && count == 0 { _ = os.Remove(filepath.Join(a.filesDir, entry.Name())) } } } func (a *App) runCleanup(ctx context.Context) { a.cleanup() ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ticker.C: a.cleanup() case <-ctx.Done(): return } } } func (a *App) serveSPA(c *gin.Context) { if a.web == nil || strings.HasPrefix(c.Request.URL.Path, "/api/") { c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) return } path := strings.TrimPrefix(filepath.Clean(c.Request.URL.Path), "/") if path != "." { if data, err := fs.ReadFile(a.web, path); err == nil { if kind := mime.TypeByExtension(filepath.Ext(path)); kind != "" { c.Header("Content-Type", kind) } c.Data(http.StatusOK, c.Writer.Header().Get("Content-Type"), data) return } } data, err := fs.ReadFile(a.web, "index.html") if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) return } c.Data(http.StatusOK, "text/html; charset=utf-8", data) } func parseUserID(c *gin.Context) (uint, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"}) return 0, false } return uint(id), true } func (a *App) listUsers(c *gin.Context) { var users []User if err := a.db.Order("id").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "读取用户失败"}) return } c.JSON(http.StatusOK, users) } func (a *App) createUser(c *gin.Context) { var req struct { Username string `json:"username"` CanManageUsers bool `json:"canManageUsers"` CanUpload bool `json:"canUpload"` CanAdvancedUpload bool `json:"canAdvancedUpload"` } if err := c.ShouldBindJSON(&req); err != nil || !usernamePattern.MatchString(req.Username) { c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"}) return } password, err := randomToken(12) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"}) return } hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) user := User{Username: req.Username, PasswordHash: string(hash), CanManageUsers: req.CanManageUsers, CanUpload: req.CanUpload, CanAdvancedUpload: req.CanAdvancedUpload, MustChangePassword: true} if err := a.db.Create(&user).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE") { c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"}) } return } c.JSON(http.StatusCreated, gin.H{"user": user, "temporaryPassword": password}) } func (a *App) updateUser(c *gin.Context) { id, ok := parseUserID(c) if !ok { return } var req struct { Username string `json:"username"` CanManageUsers bool `json:"canManageUsers"` CanUpload bool `json:"canUpload"` CanAdvancedUpload bool `json:"canAdvancedUpload"` } if err := c.ShouldBindJSON(&req); err != nil || !usernamePattern.MatchString(req.Username) { c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"}) return } var user User if err := a.db.First(&user, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return } err := a.db.Model(&user).Updates(map[string]any{"username": req.Username, "can_manage_users": req.CanManageUsers, "can_upload": req.CanUpload, "can_advanced_upload": req.CanAdvancedUpload}).Error if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"}) return } a.db.First(&user, id) c.JSON(http.StatusOK, user) } func (a *App) resetPassword(c *gin.Context) { id, ok := parseUserID(c) if !ok { return } var user User if err := a.db.First(&user, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return } password, err := randomToken(12) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"}) return } hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err := a.db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&user).Updates(map[string]any{"password_hash": string(hash), "must_change_password": true}).Error; err != nil { return err } return tx.Where("user_id = ?", id).Delete(&Session{}).Error }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"}) return } c.JSON(http.StatusOK, gin.H{"temporaryPassword": password}) } func (a *App) deleteUser(c *gin.Context) { a.uploadMu.Lock() defer a.uploadMu.Unlock() id, ok := parseUserID(c) if !ok { return } if id == 1 { c.JSON(http.StatusForbidden, gin.H{"error": "ID 为 1 的用户不能删除"}) return } var user User if err := a.db.First(&user, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return } var files []SharedFile if err := a.db.Where("owner_id = ?", id).Find(&files).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"}) return } err := a.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("user_id = ?", id).Delete(&Session{}).Error; err != nil { return err } if err := tx.Where("owner_id = ?", id).Delete(&SharedFile{}).Error; err != nil { return err } return tx.Delete(&user).Error }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"}) return } for _, file := range files { _ = os.Remove(filepath.Join(a.filesDir, file.Hash)) } c.Status(http.StatusNoContent) }