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
This commit is contained in:
@@ -3,12 +3,22 @@ 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
|
||||
@@ -29,7 +39,7 @@ func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, s
|
||||
}
|
||||
window := time.Duration(minutes) * time.Minute
|
||||
var rows []models.Throttle
|
||||
err = db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
err = fresh(ctx, db).
|
||||
Where("user_id IN ? AND (is_banned OR is_suspended)", userIDs).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
@@ -46,3 +56,213 @@ func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, s
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user