package classes import ( "context" "fmt" "log/slog" "strconv" "time" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gocloud.dev/blob" "gorm.io/gorm" ) // The functions in this file are the admin screen's user actions. Each takes // the handle of the transaction the admin write runs in and never opens one // of its own. None of them writes users_groups, except the cleanup of a user // who is being deleted for good. // ThrottleStates reads the ban and suspension state of userIDs from // user_throttle in one query. A user is banned when any of their rows is // banned, and suspended when a row is suspended and its suspended_at lies // inside the suspension window (golem15.user.throttle.suspension_minutes, // read from ctx as ContextWithThrottle stores it). It is a pure read: an // expired suspension is not lifted here. func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, suspended map[uint]bool, err error) { banned, suspended = map[uint]bool{}, map[uint]bool{} if db == nil { return nil, nil, fmt.Errorf("golem15.user: gorm handle is missing") } if len(userIDs) == 0 { return banned, suspended, nil } minutes := throttleFrom(ctx).minutes if minutes <= 0 { minutes = 15 } window := time.Duration(minutes) * time.Minute var rows []models.Throttle err = fresh(ctx, db). Where("user_id IN ? AND (is_banned OR is_suspended)", userIDs). Find(&rows).Error if err != nil { return nil, nil, err } now := time.Now() for _, row := range rows { if row.IsBanned { banned[row.UserID] = true } if row.IsSuspended && row.SuspendedAt != nil && now.Before(row.SuspendedAt.Add(window)) { suspended[row.UserID] = true } } return banned, suspended, nil } // ActivateUsers activates the users that are not active yet and returns how // many it changed. Like VerifyActivationCode it sets is_activated, stamps // activated_at, clears the activation code and restores a deactivated user. // Users that are already active are skipped. func ActivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) { if db == nil { return 0, fmt.Errorf("golem15.user: gorm handle is missing") } changed := 0 for _, user := range users { if user == nil || user.ID == 0 || user.IsActivated { continue } now := time.Now() err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{ "is_activated": true, "activated_at": now, "activation_code": nil, "activation_code_issued_at": nil, "deleted_at": nil, }).Error if err != nil { return 0, err } user.IsActivated = true user.ActivatedAt = &now user.ActivationCode = nil user.ActivationCodeIssuedAt = nil user.DeletedAt = gorm.DeletedAt{} changed++ } return changed, nil } // DeactivateUsers soft-deletes the users that are not deactivated yet, the // PHP $user->delete(), and returns how many it changed. func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) { if db == nil { return 0, fmt.Errorf("golem15.user: gorm handle is missing") } changed := 0 for _, user := range users { if user == nil || user.ID == 0 || user.DeletedAt.Valid { continue } now := time.Now() err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumn("deleted_at", now).Error if err != nil { return 0, err } user.DeletedAt = gorm.DeletedAt{Time: now, Valid: true} changed++ } return changed, nil } // RestoreUsers brings deactivated users back, the PHP $user->restore(), and // returns how many it changed. func RestoreUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) { if db == nil { return 0, fmt.Errorf("golem15.user: gorm handle is missing") } changed := 0 for _, user := range users { if user == nil || user.ID == 0 || !user.DeletedAt.Valid { continue } err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumn("deleted_at", nil).Error if err != nil { return 0, err } user.DeletedAt = gorm.DeletedAt{} changed++ } return changed, nil } // BanUsers bans the users and returns how many were not banned before. A ban // is set on every user_throttle row of the user, and a row without an IP // address is created when the user has none, so a login from any address is // refused (RejectIfThrottled falls back to that row). func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) { ids, err := userIDs(db, users) if err != nil { return 0, err } banned, _, err := ThrottleStates(ctx, db, ids) if err != nil { return 0, err } changed := 0 for _, id := range ids { if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", id).UpdateColumn("is_banned", true).Error; err != nil { return 0, err } var anyAddress int64 if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ? AND ip_address IS NULL", id).Count(&anyAddress).Error; err != nil { return 0, err } if anyAddress == 0 { if err := fresh(ctx, db).Create(&models.Throttle{UserID: id, IsBanned: true}).Error; err != nil { return 0, err } } if !banned[id] { changed++ } } return changed, nil } // UnbanUsers clears the ban on every user_throttle row of the users and // returns how many were banned before. func UnbanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) { ids, err := userIDs(db, users) if err != nil { return 0, err } banned, _, err := ThrottleStates(ctx, db, ids) if err != nil { return 0, err } changed := 0 for _, id := range ids { if !banned[id] { continue } if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", id).UpdateColumn("is_banned", false).Error; err != nil { return 0, err } changed++ } return changed, nil } // UnsuspendUser lifts a suspension: it clears is_suspended, suspended_at and // the attempt counter on every user_throttle row of the user. func UnsuspendUser(ctx context.Context, db *gorm.DB, userID uint) error { if db == nil { return fmt.Errorf("golem15.user: gorm handle is missing") } return fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", userID).UpdateColumns(map[string]any{ "is_suspended": false, "suspended_at": nil, "attempts": 0, }).Error } // ForceDeleteCleanup removes what hangs off a user who is being deleted for // good: the user_throttle rows (their foreign key has no ON DELETE), the // users_groups rows and the attachments. It runs inside the delete's // transaction and does not delete the user row; the caller does that with // the same handle. The attachments' blobs are removed only after the // transaction commits, so a rollback leaves the files in place. bucket may be // nil: the rows are still deleted. func ForceDeleteCleanup(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, user *models.User) error { if db == nil { return fmt.Errorf("golem15.user: gorm handle is missing") } if user == nil || user.ID == 0 { return fmt.Errorf("golem15.user: cannot clean up a user without an id") } if err := fresh(ctx, db).Exec(`DELETE FROM user_throttle WHERE user_id = ?`, user.ID).Error; err != nil { return err } if err := fresh(ctx, db).Exec(`DELETE FROM users_groups WHERE user_id = ?`, user.ID).Error; err != nil { return err } var keys []string err := attach.DeleteForOwner(fresh(ctx, db), user, strconv.FormatUint(uint64(user.ID), 10), func(blobKeys []string) error { keys = append(keys, blobKeys...) return nil }) if err != nil { return err } if bucket == nil || len(keys) == 0 { return nil } userID := user.ID lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) { // The rows are gone by now, so a blob that cannot be removed is // orphaned, not lost data: log it and carry on. if err := attach.DeleteKeys(ctx, bucket, keys); err != nil { slog.Default().Warn("golem15.user: remove attachment blobs of a deleted user", "user_id", userID, "err", err) } }) return nil } // fresh returns db with an empty statement on ctx, so a query never continues // from conditions the caller's handle already carries. A NewDB session is // lazy: GORM builds the empty statement on the next chain method, and a // WithContext chained directly onto it clones the caller's statement with its // conditions instead. Clauses() makes the empty statement exist first, so the // handle stays clean whatever is chained next. func fresh(ctx context.Context, db *gorm.DB) *gorm.DB { return db.Session(&gorm.Session{NewDB: true, Context: ctx}).Clauses().Session(&gorm.Session{NewDB: true}) } // userIDs returns the ids of users, skipping nil and unsaved entries. func userIDs(db *gorm.DB, users []*models.User) ([]uint, error) { if db == nil { return nil, fmt.Errorf("golem15.user: gorm handle is missing") } ids := make([]uint, 0, len(users)) for _, user := range users { if user != nil && user.ID != 0 { ids = append(ids, user.ID) } } return ids, nil }