- 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
269 lines
8.6 KiB
Go
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
|
|
}
|