Files
am-downloader/admin_users.go
T

349 lines
10 KiB
Go

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"`
Reason string `json:"reason"`
}
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", strings.TrimSpace(in.Reason)
if reason == "" {
reason = "admin credit adjustment"
}
if in.Amount < 0 {
typ = "admin_debit"
if strings.TrimSpace(in.Reason) == "" {
reason = "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))
var account CreditAccount
_ = a.db.Where("user_id = ?", id).First(&account)
c.JSON(200, gin.H{"ok": true, "balance": account.Balance, "reserved": account.Reserved, "available": account.Balance - account.Reserved})
}