808 lines
24 KiB
Go
808 lines
24 KiB
Go
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}$`)
|
|
var errFileUnavailable = errors.New("文件不存在或已失效")
|
|
var errPickupCodeRequired = errors.New("请输入取件码")
|
|
var errPickupCodeWrong = errors.New("取件码错误")
|
|
var errFileCheckFailed = errors.New("校验文件失败")
|
|
|
|
const publicFileOrCodeError = "文件不存在或取件码错误"
|
|
|
|
type App struct {
|
|
db *gorm.DB
|
|
filesDir string
|
|
web fs.FS
|
|
uploadMu sync.Mutex
|
|
downloadMu sync.Mutex
|
|
pickupMu 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.GET("/api/pickup/:hash", a.verifyPickup)
|
|
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()
|
|
}
|
|
|
|
func permissionsWithin(actor *User, canManageUsers, canUpload, canAdvancedUpload bool) bool {
|
|
if canManageUsers && !actor.CanManageUsers {
|
|
return false
|
|
}
|
|
if canAdvancedUpload && !actor.CanAdvancedUpload {
|
|
return false
|
|
}
|
|
return !canUpload || actor.CanUpload || actor.CanAdvancedUpload
|
|
}
|
|
|
|
func canManageTarget(actor, target *User) bool {
|
|
return permissionsWithin(actor, target.CanManageUsers, target.CanUpload, target.CanAdvancedUpload)
|
|
}
|
|
|
|
func requireAssignablePermissions(c *gin.Context, canManageUsers, canUpload, canAdvancedUpload bool) bool {
|
|
if permissionsWithin(currentUser(c), canManageUsers, canUpload, canAdvancedUpload) {
|
|
return true
|
|
}
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "不能授予自己不具备的权限"})
|
|
return false
|
|
}
|
|
|
|
func requireManageableTarget(c *gin.Context, target *User) bool {
|
|
if canManageTarget(currentUser(c), target) {
|
|
return true
|
|
}
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "不能管理权限高于自己的用户"})
|
|
return false
|
|
}
|
|
|
|
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) {
|
|
file, err := a.resolvePublicFile(c.Param("hash"), c.Query("code"))
|
|
if err != nil {
|
|
writePublicFileError(c, err)
|
|
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) verifyPickup(c *gin.Context) {
|
|
if _, err := a.resolvePublicFile(c.Param("hash"), c.Query("code")); err != nil {
|
|
writePublicFileError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"valid": true})
|
|
}
|
|
|
|
func (a *App) resolvePublicFile(hash, pickupCode string) (SharedFile, error) {
|
|
var file SharedFile
|
|
if err := a.db.Where("hash = ? AND ready = ?", hash, true).First(&file).Error; err != nil {
|
|
return SharedFile{}, errFileUnavailable
|
|
}
|
|
if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) {
|
|
a.removeFile(file)
|
|
return SharedFile{}, errFileUnavailable
|
|
}
|
|
if file.MaxDownloads != nil && file.DownloadCount >= *file.MaxDownloads {
|
|
a.removeFile(file)
|
|
return SharedFile{}, errFileUnavailable
|
|
}
|
|
if _, err := os.Stat(filepath.Join(a.filesDir, file.Hash)); err != nil {
|
|
a.db.Delete(&file)
|
|
return SharedFile{}, errFileUnavailable
|
|
}
|
|
if file.PickupCodeHash == "" {
|
|
return file, nil
|
|
}
|
|
if pickupCode == "" {
|
|
return SharedFile{}, a.recordPickupFailure(file, errPickupCodeRequired)
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(pickupCode)) != nil {
|
|
return SharedFile{}, a.recordPickupFailure(file, errPickupCodeWrong)
|
|
}
|
|
if err := a.clearPickupFailures(file); err != nil {
|
|
return SharedFile{}, err
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
func (a *App) recordPickupFailure(file SharedFile, failure error) error {
|
|
a.pickupMu.Lock()
|
|
defer a.pickupMu.Unlock()
|
|
result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", gorm.Expr("failed_code_attempts + 1"))
|
|
if result.Error != nil {
|
|
return errFileCheckFailed
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return errFileUnavailable
|
|
}
|
|
var latest SharedFile
|
|
if err := a.db.Select("id", "hash", "failed_code_attempts").First(&latest, file.ID).Error; err != nil {
|
|
return errFileUnavailable
|
|
}
|
|
if latest.FailedCodeAttempts < 5 {
|
|
return failure
|
|
}
|
|
a.removeFile(file)
|
|
return errFileUnavailable
|
|
}
|
|
|
|
func (a *App) clearPickupFailures(file SharedFile) error {
|
|
if file.FailedCodeAttempts == 0 {
|
|
return nil
|
|
}
|
|
a.pickupMu.Lock()
|
|
defer a.pickupMu.Unlock()
|
|
result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", 0)
|
|
if result.Error != nil {
|
|
return errFileCheckFailed
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return errFileUnavailable
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writePublicFileError(c *gin.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, errFileUnavailable), errors.Is(err, errPickupCodeWrong):
|
|
c.JSON(http.StatusNotFound, gin.H{"error": publicFileOrCodeError})
|
|
case errors.Is(err, errPickupCodeRequired):
|
|
c.JSON(http.StatusForbidden, gin.H{"error": errPickupCodeRequired.Error()})
|
|
default:
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errFileCheckFailed.Error()})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) {
|
|
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
|
|
}
|
|
if !requireManageableTarget(c, &user) || !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) {
|
|
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
|
|
}
|
|
if !requireManageableTarget(c, &user) {
|
|
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
|
|
}
|
|
if !requireManageableTarget(c, &user) {
|
|
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)
|
|
}
|