From 8b31956c6d11e0ca80863091519cc85833fdd5bb Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Thu, 17 Sep 2026 02:18:49 +0800 Subject: [PATCH] feat: implement multi-user credits redeem and storage isolation --- admin_users.go | 137 ++++++++++++++++++++++++++++++++++++++++++++++++ credits.go | 138 ++++++++++++++++++++++++++++++++++++++++++++++++ downloader.go | 5 ++ handlers.go | 50 +++++++++++++++--- index.html | 20 ++++--- main.go | 12 +++++ models.go | 8 +-- redeem.go | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ settings.go | 2 +- user_webdav.go | 106 +++++++++++++++++++++++++++++++++++++ util.go | 4 ++ webdav.go | 31 +++++++---- 12 files changed, 624 insertions(+), 28 deletions(-) create mode 100644 admin_users.go create mode 100644 credits.go create mode 100644 redeem.go create mode 100644 user_webdav.go diff --git a/admin_users.go b/admin_users.go new file mode 100644 index 0000000..cedae65 --- /dev/null +++ b/admin_users.go @@ -0,0 +1,137 @@ +package main + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func defaultPermissions(role string) []string { + if role == RolePrivileged { + return []string{PermDownload, PermRedeem, PermWebDAV, PermWebDAVConfig} + } + if role == RoleUser { + return []string{PermDownload, PermRedeem} + } + return nil +} + +func (a *App) adminListUsers(c *gin.Context) { + var users []User + if err := a.db.Order("id asc").Find(&users).Error; err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + for i := range users { + users[i].PasswordHash = "" + } + c.JSON(200, gin.H{"data": users}) +} + +func (a *App) adminCreateUser(c *gin.Context) { + var in struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + Credits int64 `json:"credits"` + } + if c.BindJSON(&in) != nil || strings.TrimSpace(in.Username) == "" || len(in.Password) < 8 { + c.JSON(400, gin.H{"error": "username and password(8+) required"}) + return + } + if in.Role == "" { + in.Role = RoleUser + } + if in.Role != RoleUser && in.Role != RolePrivileged && in.Role != RoleAdmin { + c.JSON(400, gin.H{"error": "invalid role"}) + return + } + hash, err := hashPassword(in.Password) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + var u User + err = a.db.Transaction(func(tx *gorm.DB) error { + u = User{Username: strings.TrimSpace(in.Username), PasswordHash: hash, Role: in.Role, Status: UserActive} + if err := tx.Create(&u).Error; err != nil { + return err + } + if err := tx.Create(&CreditAccount{UserID: u.ID, Balance: in.Credits}).Error; err != nil { + return err + } + for _, p := range defaultPermissions(in.Role) { + if err := tx.Create(&UserPermission{UserID: u.ID, Permission: p, Enabled: true}).Error; err != nil { + return err + } + } + if in.Credits > 0 { + if err := tx.Create(&CreditTransaction{UserID: u.ID, Type: "grant", Amount: in.Credits, BalanceAfter: in.Credits, ReferenceType: "admin", ReferenceID: strconv.FormatUint(uint64(c.MustGet("user").(*User).ID), 10), Reason: "initial credits"}).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + u.PasswordHash = "" + c.JSON(201, u) +} + +func (a *App) adminUpdateUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.Status(400) + return + } + var in struct { + Password *string `json:"password"` + Role *string `json:"role"` + Status *string `json:"status"` + } + if c.BindJSON(&in) != nil { + c.Status(400) + return + } + var u User + if a.db.First(&u, id).Error != nil { + c.Status(404) + return + } + if in.Password != nil { + if len(*in.Password) < 8 { + c.JSON(400, gin.H{"error": "password must be 8+"}) + return + } + h, e := hashPassword(*in.Password) + if e != nil { + c.JSON(500, gin.H{"error": e.Error()}) + return + } + u.PasswordHash = h + } + if in.Role != nil { + if *in.Role != RoleUser && *in.Role != RolePrivileged && *in.Role != RoleAdmin { + c.JSON(400, gin.H{"error": "invalid role"}) + return + } + u.Role = *in.Role + } + if in.Status != nil { + if *in.Status != UserActive && *in.Status != UserDisabled { + c.JSON(400, gin.H{"error": "invalid status"}) + return + } + u.Status = *in.Status + } + if err := a.db.Save(&u).Error; err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + u.PasswordHash = "" + c.JSON(200, u) +} diff --git a/credits.go b/credits.go new file mode 100644 index 0000000..717245a --- /dev/null +++ b/credits.go @@ -0,0 +1,138 @@ +package main + +import ( + "errors" + "fmt" + "strconv" + "time" + + "gorm.io/gorm" +) + +var ( + errInsufficientCredits = errors.New("insufficient credits") + errReservationState = errors.New("invalid credit reservation state") +) + +// reserveCredit places one credit on hold for a task. The hold is released on +// failure or converted into a debit after a successful new download. +func (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) { + if amount <= 0 { + return nil, fmt.Errorf("invalid credit amount") + } + var reservation CreditReservation + err := a.db.Transaction(func(tx *gorm.DB) error { + var account CreditAccount + if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + account = CreditAccount{UserID: userID} + if err := tx.Create(&account).Error; err != nil { + return err + } + } + var existing CreditReservation + if err := tx.Where("task_id = ?", taskID).First(&existing).Error; err == nil { + reservation = existing + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if account.Balance-account.Reserved < amount { + return errInsufficientCredits + } + account.Reserved += amount + if err := tx.Save(&account).Error; err != nil { + return err + } + reservation = CreditReservation{UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved"} + if err := tx.Create(&reservation).Error; err != nil { + return err + } + return tx.Create(&CreditTransaction{UserID: userID, Type: "reserve", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(taskID), 10), Reason: "download reservation"}).Error + }) + return &reservation, err +} + +func (a *App) commitCredit(task *Task) error { + if task.CreditReservationID == 0 { + return nil + } + return a.db.Transaction(func(tx *gorm.DB) error { + var r CreditReservation + if err := tx.First(&r, task.CreditReservationID).Error; err != nil { + return err + } + if r.Status == "committed" { + return nil + } + if r.Status != "reserved" { + return errReservationState + } + var account CreditAccount + if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil { + return err + } + account.Reserved -= r.Amount + account.Balance -= r.Amount + if account.Reserved < 0 || account.Balance < 0 { + return errReservationState + } + if err := tx.Save(&account).Error; err != nil { + return err + } + now := time.Now() + r.Status, r.CompletedAt = "committed", &now + if err := tx.Save(&r).Error; err != nil { + return err + } + return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "debit", Amount: -r.Amount, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "successful download"}).Error + }) +} + +// refundCredit releases a reservation without debiting the user's balance. +// It is idempotent so download failure paths can safely call fail repeatedly. +func (a *App) refundCredit(task *Task) error { + if task.CreditReservationID == 0 { + return nil + } + return a.db.Transaction(func(tx *gorm.DB) error { + var r CreditReservation + if err := tx.First(&r, task.CreditReservationID).Error; err != nil { + return err + } + if r.Status == "refunded" { + return nil + } + if r.Status == "committed" { + return errReservationState + } + if r.Status != "reserved" { + return errReservationState + } + var account CreditAccount + if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil { + return err + } + account.Reserved -= r.Amount + if account.Reserved < 0 { + return errReservationState + } + if err := tx.Save(&account).Error; err != nil { + return err + } + now := time.Now() + r.Status, r.CompletedAt = "refunded", &now + if err := tx.Save(&r).Error; err != nil { + return err + } + return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "refund", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "download failed"}).Error + }) +} + +func (a *App) creditBalance(userID uint) (CreditAccount, error) { + var account CreditAccount + err := a.db.Where("user_id = ?", userID).First(&account).Error + return account, err +} diff --git a/downloader.go b/downloader.go index 820eb03..74f6a69 100644 --- a/downloader.go +++ b/downloader.go @@ -71,6 +71,11 @@ func (a *App) finishExisting(t *Task, size int64) { } t.TotalBytes, t.DownloadedBytes, t.Progress, t.Status = size, size, 100, "done" a.db.Save(t) + // Existing local media is reused and therefore does not consume a credit. + if err := a.refundCredit(t); err != nil { + a.fail(t, err) + return + } a.upload(t) } diff --git a/handlers.go b/handlers.go index 13239b0..265931e 100644 --- a/handlers.go +++ b/handlers.go @@ -35,7 +35,12 @@ func (a *App) search(c *gin.Context) { func (a *App) listTasks(c *gin.Context) { var ts []Task - if err := a.db.Order("id desc").Limit(100).Find(&ts).Error; err != nil { + user := c.MustGet("user").(*User) + query := a.db.Where("user_id = ?", user.ID) + if user.Role == RoleAdmin && c.Query("all") == "1" { + query = a.db + } + if err := query.Order("id desc").Limit(100).Find(&ts).Error; err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } @@ -54,31 +59,60 @@ func (a *App) addTask(c *gin.Context) { if in.Quality == "" { in.Quality = a.cfg.DefaultQuality } + user := c.MustGet("user").(*User) var existing Task - err := a.db.Where("source = ? AND r_id = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error + err := a.db.Where("user_id = ? AND source = ? AND r_id = ? AND quality = ?", user.ID, in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error if err == nil { if existing.Status != "error" { c.JSON(200, existing) return } - a.db.Model(&existing).Updates(map[string]any{"title": in.Title, "artist": in.Artist, "album": in.Album, "pic": in.Pic, "status": "pending", "error": ""}) + r, reserveErr := a.reserveCredit(user.ID, existing.ID, 1) + if reserveErr != nil { + c.JSON(402, gin.H{"error": reserveErr.Error()}) + return + } + a.db.Model(&existing).Updates(map[string]any{"title": in.Title, "artist": in.Artist, "album": in.Album, "pic": in.Pic, "status": "pending", "error": "", "credit_reservation_id": r.ID}) + existing.CreditReservationID = r.ID a.queue <- existing.ID c.JSON(202, existing) return } - t := Task{Source: in.Source, RID: in.RID, Title: in.Title, Artist: in.Artist, Album: in.Album, Pic: in.Pic, Quality: in.Quality, Status: "pending"} + t := Task{UserID: user.ID, Source: in.Source, RID: in.RID, Title: in.Title, Artist: in.Artist, Album: in.Album, Pic: in.Pic, Quality: in.Quality, Status: "pending"} if err := a.db.Create(&t).Error; err != nil { - if a.db.Where("source = ? AND r_id = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error == nil { + if a.db.Where("user_id = ? AND source = ? AND r_id = ? AND quality = ?", user.ID, in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error == nil { c.JSON(200, existing) return } c.JSON(500, gin.H{"error": err.Error()}) return } + r, reserveErr := a.reserveCredit(user.ID, t.ID, 1) + if reserveErr != nil { + _ = a.db.Delete(&t).Error + c.JSON(402, gin.H{"error": reserveErr.Error()}) + return + } + t.CreditReservationID = r.ID + if err := a.db.Model(&t).Update("credit_reservation_id", r.ID).Error; err != nil { + _ = a.refundCredit(&t) + c.JSON(500, gin.H{"error": err.Error()}) + return + } a.queue <- t.ID c.JSON(202, t) } +func (a *App) creditInfo(c *gin.Context) { + user := c.MustGet("user").(*User) + account, err := a.creditBalance(user.ID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"balance": account.Balance, "reserved": account.Reserved, "available": account.Balance - account.Reserved}) +} + func (a *App) retryTask(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -86,7 +120,8 @@ func (a *App) retryTask(c *gin.Context) { return } var t Task - if a.db.First(&t, id).Error != nil { + user := c.MustGet("user").(*User) + if a.db.Where("id = ? AND user_id = ?", id, user.ID).First(&t).Error != nil { c.Status(404) return } @@ -102,7 +137,8 @@ func (a *App) deleteTask(c *gin.Context) { c.Status(400) return } - if err := a.db.Delete(&Task{}, id).Error; err != nil { + user := c.MustGet("user").(*User) + if err := a.db.Where("id = ? AND user_id = ?", id, user.ID).Delete(&Task{}).Error; err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } diff --git a/index.html b/index.html index d812ff5..3d51a17 100644 --- a/index.html +++ b/index.html @@ -5,10 +5,11 @@ -
-
AM Downloader
搜索 · 下载 · WebDAV 同步
+
+
AM Downloader
搜索 · 下载 · WebDAV 同步

找到你想听的音乐

搜索歌曲、歌手或专辑,加入下载队列即可。

最近下载
@@ -17,7 +18,12 @@
diff --git a/main.go b/main.go index cf46d6e..6b827ad 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,8 @@ func main() { db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) must(err) must(db.AutoMigrate(&Task{}, &Setting{}, &User{}, &UserPermission{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}, &RedeemBatch{}, &RedeemCode{}, &RedeemRecord{}, &WebDAVConfig{}, &Session{}, &AuditLog{})) + // Replace the legacy global task uniqueness constraint with per-user uniqueness. + must(db.Exec("DROP INDEX IF EXISTS idx_tasks_source_rid_quality").Error) client := &http.Client{Timeout: 2 * 60 * 1e9} cfg := loadConfig(db) @@ -38,10 +40,20 @@ func main() { auth := api.Group("", a.requireAuth) auth.GET("/search", a.requirePermission(PermDownload), a.search) auth.GET("/tasks", a.requirePermission(PermDownload), a.listTasks) + auth.GET("/credits", a.creditInfo) + auth.POST("/redeem", a.requirePermission(PermRedeem), a.redeem) + auth.GET("/webdav/config", a.requirePermission(PermWebDAVConfig), a.getUserWebDAV) + auth.PUT("/webdav/config", a.requirePermission(PermWebDAVConfig), a.putUserWebDAV) + auth.POST("/webdav/test", a.requirePermission(PermWebDAVConfig), a.testUserWebDAV) auth.POST("/tasks", a.requirePermission(PermDownload), a.addTask) auth.POST("/tasks/:id/retry", a.requirePermission(PermDownload), a.retryTask) auth.DELETE("/tasks/:id", a.requirePermission(PermDownload), a.deleteTask) admin := api.Group("/admin", a.requireAuth, a.requireAdmin) + admin.GET("/users", a.adminListUsers) + admin.POST("/users", a.adminCreateUser) + admin.PUT("/users/:id", a.adminUpdateUser) + admin.GET("/redeem", a.adminListRedeemBatches) + admin.POST("/redeem", a.adminCreateRedeemBatch) admin.GET("/settings", a.getSettings) admin.PUT("/settings", a.putSettings) admin.POST("/webdav/test", a.testWebDAV) diff --git a/models.go b/models.go index 0416d24..9022de2 100644 --- a/models.go +++ b/models.go @@ -4,15 +4,15 @@ import "time" type Task struct { ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"index" json:"userId"` + UserID uint `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"userId"` CreditReservationID uint `gorm:"index" json:"creditReservationId,omitempty"` - Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"` - RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"` + Source string `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"source"` + RID string `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"rid"` Title string `json:"title"` Artist string `json:"artist"` Album string `json:"album"` Pic string `json:"pic"` - Quality string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"quality"` + Quality string `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"quality"` ResolvedQuality string `json:"resolvedQuality,omitempty"` ActualQuality string `json:"actualQuality,omitempty"` Format string `json:"format"` diff --git a/redeem.go b/redeem.go new file mode 100644 index 0000000..c39c270 --- /dev/null +++ b/redeem.go @@ -0,0 +1,139 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func hashRedeemCode(code string) string { + s := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code)))) + return hex.EncodeToString(s[:]) +} + +func generateRedeemCode() (string, error) { + b := make([]byte, 9) + if _, err := rand.Read(b); err != nil { + return "", err + } + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + out := make([]byte, 12) + for i := range out { + out[i] = alphabet[int(b[i%len(b)])%len(alphabet)] + } + return string(out[:4]) + "-" + string(out[4:8]) + "-" + string(out[8:]), nil +} + +func (a *App) adminCreateRedeemBatch(c *gin.Context) { + var in struct { + Credits int64 `json:"credits"` + Count int `json:"count"` + ExpiresAt *time.Time `json:"expiresAt"` + Note string `json:"note"` + } + if c.BindJSON(&in) != nil || in.Credits <= 0 || in.Count < 1 || in.Count > 10000 { + c.JSON(400, gin.H{"error": "credits/count invalid"}) + return + } + admin := c.MustGet("user").(*User) + batch := RedeemBatch{Code: fmt.Sprintf("B-%d", time.Now().UnixNano()), Credits: in.Credits, Count: in.Count, ExpiresAt: in.ExpiresAt, Note: in.Note, CreatedBy: admin.ID} + codes := make([]string, 0, in.Count) + err := a.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&batch).Error; err != nil { + return err + } + for i := 0; i < in.Count; i++ { + code, err := generateRedeemCode() + if err != nil { + return err + } + row := RedeemCode{BatchID: batch.ID, CodeHash: hashRedeemCode(code), Prefix: code[:4], Credits: in.Credits, Status: "unused", ExpiresAt: in.ExpiresAt} + if err := tx.Create(&row).Error; err != nil { + return err + } + codes = append(codes, code) + } + return nil + }) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(201, gin.H{"batch": batch, "codes": codes}) +} + +func (a *App) redeem(c *gin.Context) { + user := c.MustGet("user").(*User) + var in struct { + Code string `json:"code"` + } + if c.BindJSON(&in) != nil || strings.TrimSpace(in.Code) == "" { + c.JSON(400, gin.H{"error": "code required"}) + return + } + now := time.Now() + var amount int64 + err := a.db.Transaction(func(tx *gorm.DB) error { + var code RedeemCode + if err := tx.Where("code_hash = ?", hashRedeemCode(in.Code)).First(&code).Error; err != nil { + return err + } + if code.Status != "unused" { + return errors.New("code already used or unavailable") + } + if code.ExpiresAt != nil && code.ExpiresAt.Before(now) { + return errors.New("code expired") + } + var account CreditAccount + if err := tx.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + account = CreditAccount{UserID: user.ID} + if err := tx.Create(&account).Error; err != nil { + return err + } + } + account.Balance += code.Credits + if err := tx.Save(&account).Error; err != nil { + return err + } + code.Status, code.RedeemedBy, code.RedeemedAt = "used", &user.ID, &now + if err := tx.Save(&code).Error; err != nil { + return err + } + if err := tx.Create(&RedeemRecord{CodeID: code.ID, BatchID: code.BatchID, UserID: user.ID, Credits: code.Credits}).Error; err != nil { + return err + } + if err := tx.Create(&CreditTransaction{UserID: user.ID, Type: "redeem", Amount: code.Credits, BalanceAfter: account.Balance, ReferenceType: "redeem_code", ReferenceID: fmt.Sprint(code.ID), Reason: "redeem code"}).Error; err != nil { + return err + } + amount = code.Credits + return nil + }) + if err != nil { + status := 500 + if errors.Is(err, gorm.ErrRecordNotFound) { + status = 404 + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"credits": amount}) +} + +func (a *App) adminListRedeemBatches(c *gin.Context) { + var batches []RedeemBatch + if err := a.db.Order("id desc").Limit(100).Find(&batches).Error; err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"data": batches}) +} diff --git a/settings.go b/settings.go index 4894eff..650805e 100644 --- a/settings.go +++ b/settings.go @@ -12,7 +12,7 @@ func (a *App) getSettings(c *gin.Context) { c.JSON(200, gin.H{ "amApi": a.cfg.AMAPI, "downloadDir": a.cfg.DownloadDir, "webdavUrl": a.cfg.WebDAVURL, "webdavUser": a.cfg.WebDAVUser, - "webdavPass": a.cfg.WebDAVPass, "webdavPath": a.cfg.WebDAVPath, + "webdavPass": "", "hasWebdavPassword": a.cfg.WebDAVPass != "", "webdavPath": a.cfg.WebDAVPath, "quality": a.cfg.DefaultQuality, "webdavVersionPolicy": a.cfg.WebDAVVersionPolicy, "localFilePolicy": a.cfg.LocalFilePolicy, }) diff --git a/user_webdav.go b/user_webdav.go new file mode 100644 index 0000000..33d39c2 --- /dev/null +++ b/user_webdav.go @@ -0,0 +1,106 @@ +package main + +import ( + "path" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/studio-b12/gowebdav" +) + +func cleanWebDAVRoot(root string) string { + root = strings.TrimSpace(strings.ReplaceAll(root, "\\", "/")) + root = path.Clean("/" + root) + if root == "/" { + return "" + } + return strings.Trim(root, "/") +} + +func (a *App) getUserWebDAV(c *gin.Context) { + user := c.MustGet("user").(*User) + var cfg WebDAVConfig + if err := a.db.Where("user_id = ?", user.ID).First(&cfg).Error; err != nil { + c.JSON(200, gin.H{"enabled": false, "url": "", "username": "", "rootPath": "", "hasPassword": false}) + return + } + c.JSON(200, gin.H{"enabled": cfg.Enabled, "url": cfg.URL, "username": cfg.Username, "rootPath": cfg.RootPath, "hasPassword": cfg.Password != ""}) +} + +func (a *App) putUserWebDAV(c *gin.Context) { + user := c.MustGet("user").(*User) + var in struct { + Enabled bool `json:"enabled"` + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + RootPath string `json:"rootPath"` + } + if c.BindJSON(&in) != nil { + c.Status(400) + return + } + root := cleanWebDAVRoot(in.RootPath) + var cfg WebDAVConfig + err := a.db.Where("user_id = ?", user.ID).First(&cfg).Error + if err != nil { + cfg = WebDAVConfig{UserID: user.ID, Enabled: in.Enabled, URL: strings.TrimRight(strings.TrimSpace(in.URL), "/"), Username: strings.TrimSpace(in.Username), Password: in.Password, RootPath: root} + } else { + cfg.Enabled = in.Enabled + cfg.URL = strings.TrimRight(strings.TrimSpace(in.URL), "/") + cfg.Username = strings.TrimSpace(in.Username) + cfg.RootPath = root + if in.Password != "" { + cfg.Password = in.Password + } + } + if err := a.db.Save(&cfg).Error; err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"ok": true}) +} + +func (a *App) testUserWebDAV(c *gin.Context) { + user := c.MustGet("user").(*User) + var in struct{ URL, Username, Password, RootPath string } + if c.BindJSON(&in) != nil { + c.Status(400) + return + } + if in.URL == "" { + var cfg WebDAVConfig + if a.db.Where("user_id = ?", user.ID).First(&cfg).Error == nil { + in.URL, in.Username, in.Password, in.RootPath = cfg.URL, cfg.Username, cfg.Password, cfg.RootPath + } + } + client := gowebdav.NewClient(in.URL, in.Username, in.Password) + client.SetTimeout(20 * time.Second) + if err := client.Connect(); err != nil { + c.JSON(502, gin.H{"ok": false, "error": err.Error()}) + return + } + c.JSON(200, gin.H{"ok": true, "rootPath": cleanWebDAVRoot(in.RootPath)}) +} + +func (a *App) storageForTask(t *Task) (RemoteStorage, string, bool) { + var user User + if a.db.First(&user, t.UserID).Error != nil { + return nil, "", false + } + if user.Role == RoleAdmin { + if a.cfg.WebDAVURL == "" { + return nil, "", false + } + return a.storage, a.cfg.WebDAVPath, a.storage != nil + } + if !a.hasPermission(user.ID, PermWebDAV) { + return nil, "", false + } + var cfg WebDAVConfig + if a.db.Where("user_id = ? AND enabled = ?", user.ID, true).First(&cfg).Error != nil || cfg.URL == "" { + return nil, "", false + } + return NewWebDAVStorage(Config{WebDAVURL: cfg.URL, WebDAVUser: cfg.Username, WebDAVPass: cfg.Password}), cfg.RootPath, true +} diff --git a/util.go b/util.go index a54d702..49b4119 100644 --- a/util.go +++ b/util.go @@ -18,6 +18,10 @@ func (a *App) fail(t *Task, err error) { } t.Status, t.Error = "error", err.Error() a.db.Save(t) + if refundErr := a.refundCredit(t); refundErr != nil { + t.Error += "; credit refund failed: " + refundErr.Error() + a.db.Save(t) + } } var bad = regexp.MustCompile(`[/\\:*?"<>|]+`) diff --git a/webdav.go b/webdav.go index 259e99d..99aba9c 100644 --- a/webdav.go +++ b/webdav.go @@ -7,35 +7,48 @@ import ( ) func (a *App) upload(t *Task) { - if a.storage == nil || a.cfg.WebDAVURL == "" { + storage, root, enabled := a.storageForTask(t) + if !enabled || storage == nil || root == "" { t.Status, t.Progress = "done", 100 a.db.Save(t) + if err := a.commitCredit(t); err != nil { + a.fail(t, err) + return + } return } a.db.Model(t).Updates(map[string]any{"status": "uploading"}) - remote := strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + safe(t.Artist) + "/" + safe(t.Album) + "/" + safe(t.Title) + "." + safe(t.Format) + remote := strings.TrimRight(root, "/") + "/" + safe(t.Artist) + "/" + safe(t.Album) + "/" + safe(t.Title) + "." + safe(t.Format) t.RemotePath = remote - if err := a.storage.MkdirAll(filepath.ToSlash(filepath.Dir(remote))); err != nil { + if err := storage.MkdirAll(filepath.ToSlash(filepath.Dir(remote))); err != nil { a.fail(t, err) return } if fi, err := os.Stat(t.LocalPath); err == nil { - if same, _ := a.storage.ExistsSameSize(remote, fi.Size()); same { + if same, _ := storage.ExistsSameSize(remote, fi.Size()); same { t.Status, t.Progress = "done", 100 a.db.Save(t) + if err := a.commitCredit(t); err != nil { + a.fail(t, err) + return + } a.cleanupLocalFile(t) return } } - if err := a.storage.Upload(t.LocalPath, remote); err != nil { + if err := storage.Upload(t.LocalPath, remote); err != nil { a.fail(t, err) return } if a.cfg.WebDAVVersionPolicy == "replace_lower" { - a.cleanupLowerQualityVersions(t) + a.cleanupLowerQualityVersions(t, storage) } t.Status, t.Progress = "done", 100 a.db.Save(t) + if err := a.commitCredit(t); err != nil { + a.fail(t, err) + return + } a.cleanupLocalFile(t) } @@ -49,20 +62,20 @@ func (a *App) cleanupLocalFile(t *Task) { } } -func (a *App) cleanupLowerQualityVersions(t *Task) { +func (a *App) cleanupLowerQualityVersions(t *Task, storage RemoteStorage) { levels := map[string]int{"standard": 1, "exhigh": 2, "sq": 3, "lossless": 4, "hires": 5, "jyeffect": 6, "sky": 7, "jymaster": 8, "atmos": 9, "atmos_plus": 10, "master": 11} current := qualityRank(levels, t.ActualQuality, t.ResolvedQuality, t.Quality) if current == 0 { return } var old []Task - a.db.Where("artist = ? AND title = ? AND album = ? AND status = ? AND id <> ?", t.Artist, t.Title, t.Album, "done", t.ID).Find(&old) + a.db.Where("user_id = ? AND artist = ? AND title = ? AND album = ? AND status = ? AND id <> ?", t.UserID, t.Artist, t.Title, t.Album, "done", t.ID).Find(&old) for _, candidate := range old { q := qualityRank(levels, candidate.ActualQuality, candidate.ResolvedQuality, candidate.Quality) if q == 0 || q >= current || candidate.RemotePath == "" || candidate.RemotePath == t.RemotePath { continue } - _ = a.storage.Remove(candidate.RemotePath) + _ = storage.Remove(candidate.RemotePath) } }