Files
sm-user-plugin/classes/admin_actions.go
Jakub Zych 970eba7734 feat: user preview, bulk and record actions and the permanent delete
- the list opens the preview, which shows one status hint (banned,
  deactivated, not activated, suspended) and the IP address fields
- bulk actions activate, deactivate, restore, ban and unban; record
  actions activate, unban and unsuspend, offered while they apply
- delete (form button and bulk) removes the user for good with the
  throttle rows, group memberships and attachments in one transaction;
  blobs go after the commit
- a privileged-group member is still refused, a bulk delete as a whole
2026-10-05 13:10:12 +02:00

269 lines
8.6 KiB
Go

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.
func fresh(ctx context.Context, db *gorm.DB) *gorm.DB {
return db.Session(&gorm.Session{NewDB: true}).WithContext(ctx)
}
// 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
}