package main import ( "errors" "fmt" "strconv" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" ) func (a *App) audit(operator uint, action, targetType, targetID, metadata string) { _ = a.db.Create(&AuditLog{OperatorID: operator, Action: action, TargetType: targetType, TargetID: targetID, Metadata: metadata}).Error } func defaultPermissions(role string) []string { if role == RolePrivileged { return []string{PermDownload, PermRedeem, PermWebDAV, PermWebDAVConfig} } if role == RoleUser { return []string{PermDownload, PermRedeem, PermWebDAV} } 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 } type userView struct { User Balance int64 `json:"balance"` Reserved int64 `json:"reserved"` Available int64 `json:"available"` TaskCount int64 `json:"taskCount"` CompletedCount int64 `json:"completedCount"` } out := make([]userView, 0, len(users)) for i := range users { u := users[i] u.PasswordHash = "" var account CreditAccount _ = a.db.Where("user_id = ?", u.ID).First(&account).Error var taskCount, completedCount int64 _ = a.db.Model(&Task{}).Where("user_id = ?", u.ID).Count(&taskCount).Error _ = a.db.Model(&Task{}).Where("user_id = ? AND status = ?", u.ID, "done").Count(&completedCount).Error out = append(out, userView{User: u, Balance: account.Balance, Reserved: account.Reserved, Available: account.Balance - account.Reserved, TaskCount: taskCount, CompletedCount: completedCount}) } c.JSON(200, gin.H{"data": out}) } 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 } admin := c.MustGet("user").(*User) 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 = "" a.audit(admin.ID, "user.create", "user", strconv.FormatUint(uint64(u.ID), 10), "role="+u.Role) c.JSON(201, u) } func (a *App) adminAdjustUserCredits(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(400, gin.H{"error": "invalid user id"}) return } var in struct { Amount int64 `json:"amount"` Reason string `json:"reason"` } if c.BindJSON(&in) != nil || in.Amount == 0 { c.JSON(400, gin.H{"error": "amount must not be zero"}) return } operator := c.MustGet("user").(*User) var account CreditAccount err = a.db.Transaction(func(tx *gorm.DB) error { var u User if err := tx.First(&u, id).Error; err != nil { return err } if err := tx.Where("user_id = ?", u.ID).First(&account).Error; err != nil { if err != gorm.ErrRecordNotFound { return err } account = CreditAccount{UserID: u.ID} if err := tx.Create(&account).Error; err != nil { return err } } newBalance := account.Balance + in.Amount if newBalance < account.Reserved { return errors.New("balance cannot be lower than reserved credits") } account.Balance = newBalance if err := tx.Save(&account).Error; err != nil { return err } reason := strings.TrimSpace(in.Reason) if reason == "" { reason = "admin credit adjustment" } return tx.Create(&CreditTransaction{UserID: u.ID, Type: "adjust", Amount: in.Amount, BalanceAfter: account.Balance, ReferenceType: "admin", ReferenceID: strconv.FormatUint(uint64(operator.ID), 10), Reason: reason}).Error }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(404, gin.H{"error": "user not found"}) return } c.JSON(400, gin.H{"error": err.Error()}) return } a.audit(operator.ID, "user.credit.adjust", "user", strconv.FormatUint(id, 10), fmt.Sprintf("amount=%d;reason=%s", in.Amount, strings.TrimSpace(in.Reason))) c.JSON(200, gin.H{"balance": account.Balance, "reserved": account.Reserved, "available": account.Balance - account.Reserved}) } func (a *App) adminAudit(c *gin.Context) { var logs []AuditLog q := a.db.Order("id desc").Limit(200) if action := c.Query("action"); action != "" { q = q.Where("action = ?", action) } if err := q.Find(&logs).Error; err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"data": logs}) } 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 } operator := c.MustGet("user").(*User) if u.ID == operator.ID && (u.Role != RoleAdmin || u.Status == UserDisabled) { var admins int64 if err := a.db.Model(&User{}).Where("role = ? AND status = ?", RoleAdmin, UserActive).Count(&admins).Error; err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } if admins <= 1 { c.JSON(400, gin.H{"error": "cannot remove the last active admin account"}) return } } if err := a.db.Transaction(func(tx *gorm.DB) error { if err := tx.Save(&u).Error; err != nil { return err } if in.Password != nil { if err := tx.Where("user_id = ?", u.ID).Delete(&Session{}).Error; err != nil { return err } } if in.Role != nil { if err := tx.Where("user_id = ?", u.ID).Delete(&UserPermission{}).Error; err != nil { return err } for _, p := range defaultPermissions(u.Role) { if err := tx.Create(&UserPermission{UserID: u.ID, Permission: p, Enabled: true}).Error; err != nil { return err } } } return nil }); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } u.PasswordHash = "" changes := []string{} if in.Password != nil { changes = append(changes, "password") } if in.Role != nil { changes = append(changes, "role="+u.Role) } if in.Status != nil { changes = append(changes, "status="+u.Status) } a.audit(operator.ID, "user.update", "user", strconv.FormatUint(uint64(u.ID), 10), strings.Join(changes, ",")) c.JSON(200, u) } func (a *App) adminUserCredits(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.Status(400) return } var account CreditAccount if err := a.db.Where("user_id = ?", id).First(&account).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(404, gin.H{"error": "credit account not found"}) return } c.JSON(500, gin.H{"error": err.Error()}) return } var tasks int64 if err := a.db.Model(&Task{}).Where("user_id = ?", id).Count(&tasks).Error; 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, "tasks": tasks}) } func (a *App) adminAdjustCredits(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.Status(400) return } var in struct { Amount int64 `json:"amount"` } if c.BindJSON(&in) != nil || in.Amount == 0 { c.JSON(400, gin.H{"error": "amount cannot be zero"}) return } operator := c.MustGet("user").(*User) err = a.db.Transaction(func(tx *gorm.DB) error { var account CreditAccount if err := tx.Where("user_id = ?", id).First(&account).Error; err != nil { return err } if in.Amount < 0 && account.Balance+in.Amount < account.Reserved { return errors.New("cannot reduce balance below reserved credits") } account.Balance += in.Amount if account.Balance < 0 { return errors.New("insufficient balance") } if err := tx.Save(&account).Error; err != nil { return err } typ, reason := "grant", "admin credit adjustment" if in.Amount < 0 { typ, reason = "admin_debit", "admin credit deduction" } return tx.Create(&CreditTransaction{UserID: uint(id), Type: typ, Amount: in.Amount, BalanceAfter: account.Balance, ReferenceType: "admin", ReferenceID: strconv.FormatUint(uint64(operator.ID), 10), Reason: reason}).Error }) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } a.audit(operator.ID, "user.credits.adjust", "user", strconv.FormatUint(id, 10), "amount="+strconv.FormatInt(in.Amount, 10)) c.JSON(200, gin.H{"ok": true}) }