package controllers import ( "context" "errors" "fmt" "strconv" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "gocloud.dev/blob" "gorm.io/gorm" ) // PermissionAccessUsers is the backend permission of the Users screen, the // PHP Users::$requiredPermissions. const PermissionAccessUsers = "golem15.users.access_users" // usersLang prefixes the phrase keys of the Users screen. const usersLang = "golem15.user::lang.users." // usersAdminController is the Winter Users backend controller: the list with // its filters and row states, and the form. The screen and every write need // golem15.users.access_users. // // app is resolved per call from the booted plugin. Nothing is cached: the // database handle, the config and the privileged group list are read when a // request needs them. type usersAdminController struct { app func() *backpack.App } func (usersAdminController) ID() string { return "golem15.user.users" } func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` } func (usersAdminController) ConfigDir() string { return "controllers/users" } func (usersAdminController) RequiredPermissions() []string { return []string{PermissionAccessUsers} } func (usersAdminController) NewRecord() any { return &models.User{} } // application returns the booted app, or nil before boot. func (c usersAdminController) application() *backpack.App { if c.app == nil { return nil } return c.app() } // db is the application pool, or nil when the app has none. func (c usersAdminController) db() *gorm.DB { app := c.application() if app == nil { return nil } gdb, ok := app.Lookup[*gorm.DB]() if !ok { return nil } return gdb } // config is the application config, or nil before boot. func (c usersAdminController) config() *compass.Config { app := c.application() if app == nil { return nil } return app.Config } // ListExtendQuery and FormExtendQuery include deactivated (soft-deleted) // users, the PHP listExtendQuery and formExtendQuery withTrashed(). func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { if db == nil { return db } return db.Unscoped() } func (usersAdminController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { if db == nil { return db } return db.Unscoped() } // ListRowStates is the PHP listInjectRowClass: deleted for a deactivated // user, negative for a banned one, disabled for one who is not activated. // The ban state of the whole page comes from one user_throttle query. func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) { ids := make([]uint, 0, len(records)) for _, record := range records { user, ok := record.(*models.User) if !ok || user == nil { return nil, errors.New("users admin: list record is not a user") } ids = append(ids, user.ID) } banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, c.application()), db, ids) if err != nil { return nil, err } out := make([][]pact.RowState, len(records)) for i, record := range records { user := record.(*models.User) if user.DeletedAt.Valid { out[i] = append(out[i], pact.RowStateDeleted) } if banned[user.ID] { out[i] = append(out[i], pact.RowStateNegative) } if !user.IsActivated { out[i] = append(out[i], pact.RowStateDisabled) } } return out, nil } // FormRules are the rules of an admin save. They replace User.Rules(), which // is the register contract and would ask every admin update for a confirmed // password. func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string { return map[string]string{ "email": "required|between:6,255|email|unique:users", } } // FilterOptions serves the choices of the groups filter (scope // filterByGroup): every user group by name. Any other scope has no choices. func (c usersAdminController) FilterOptions(scope string) []pact.Option { if scope != models.FilterByGroup { return nil } db := c.db() if db == nil { return nil } var groups []models.UserGroup if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&groups).Error; err != nil { return nil } out := make([]pact.Option, 0, len(groups)) for _, group := range groups { out = append(out, pact.Option{Value: strconv.FormatUint(uint64(group.ID), 10), Label: group.Name}) } return out } // guardPrivilegedMember protects the account of a privileged-group member // against takeover. It answers nil when the administrator on ctx holds // golem15.users.manage_privileged_groups, or when the user is not a member of // a group whose code is in golem15.user.privileged_groups; otherwise it // refuses with a 403 carrying message and details. Membership is read // through the write's transaction. A failed query is returned as it is. func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID uint, message string, details map[string]any) error { principal, _ := bouncer.User(ctx) if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) { return nil } db := requestDB(ctx, c.db) if db == nil { return errors.New("users admin: gorm handle is missing") } privileged, err := classes.IsPrivilegedMember(ctx, db, classes.PrivilegedGroupCodes(c.config()), userID) if err != nil { return err } if !privileged { return nil } return &cabana.ForbiddenError{Message: message, Details: details} } // FormBeforeUpdate starts with the takeover guard: a changed email or a // submitted password on a member of a privileged group needs // golem15.users.manage_privileged_groups. Everything else an update does // comes after it. func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error { user, ok := model.(*models.User) if !ok || user == nil { return errors.New("users admin: form model is not a user") } if err := c.guardCredentials(ctx, user); err != nil { return err } return nil } // guardCredentials compares the email the save would write with the stored // one and looks for a submitted password, then asks guardPrivilegedMember // for the fields that would change. func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error { db := requestDB(ctx, c.db) if db == nil { return errors.New("users admin: gorm handle is missing") } var stored []string err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error if err != nil { return err } if len(stored) != 1 { return errors.New("users admin: the user being updated was not found") } const message = usersLang + "privileged_member_forbidden" details := map[string]any{} if stored[0] != user.Email { details["email"] = []string{message} } if values, ok := cabana.VirtualFieldsFromContext(ctx); ok { if password, _ := values["password"].(string); password != "" { details["password"] = []string{message} } } if len(details) == 0 { return nil } return c.guardPrivilegedMember(ctx, user.ID, message, details) } // FormBeforeDelete refuses the delete of a privileged-group member without // golem15.users.manage_privileged_groups. The framework calls it for the // form's delete and once per record inside the bulk delete's transaction, so // one refused record rolls the whole selection back. func (c usersAdminController) FormBeforeDelete(ctx context.Context, model any) error { user, ok := model.(*models.User) if !ok || user == nil { return errors.New("users admin: form model is not a user") } return c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_delete_forbidden", nil) } // FormAfterDelete makes the delete permanent, the PHP update_onDelete and // bulk delete forceDelete(): the list keeps deactivated users, so the // framework's soft delete alone would change nothing an administrator can // see. The user's throttle rows, group memberships and attachments go in the // same transaction; the files' blobs are removed after it commits. // FormBeforeDelete has already refused a privileged-group member. func (c usersAdminController) FormAfterDelete(ctx context.Context, model any) error { user, ok := model.(*models.User) if !ok || user == nil { return errors.New("users admin: form model is not a user") } tx, ok := cabana.TxFromContext(ctx) if !ok { return errors.New("users admin: a delete needs its transaction") } if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil { return err } return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). Where("id = ?", user.ID).Delete(&models.User{}).Error } // bucket is the application's blob bucket, or nil when none is published. func (c usersAdminController) bucket() *blob.Bucket { app := c.application() if app == nil { return nil } bucket, ok := app.Lookup[*blob.Bucket]() if !ok { return nil } return bucket } // usersOf asserts that every record of a bulk action is a user. func usersOf(records []any) ([]*models.User, error) { users := make([]*models.User, 0, len(records)) for _, record := range records { user, ok := record.(*models.User) if !ok || user == nil { return nil, errors.New("users admin: bulk record is not a user") } users = append(users, user) } return users, nil } // AdminBulkActions are the PHP index_onBulkAction cases next to the built-in // delete: activate, deactivate, restore, ban and unban. Each needs only // golem15.users.access_users, changes the users it applies to and reports how // many that were. The fixed success phrase is returned only when every // selected user was changed; otherwise the framework shows its count text, // which never overstates. None of them touches users_groups. func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction { bulk := func(name string, run func(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error)) pact.AdminBulkAction { return pact.AdminBulkAction{ Name: name, Label: usersLang + name + "_selected", Confirm: usersLang + name + "_selected_confirm", Permissions: []string{PermissionAccessUsers}, Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) { users, err := usersOf(in.Records) if err != nil { return pact.AdminBulkActionResult{}, err } tx, ok := cabana.TxFromContext(ctx) if !ok { return pact.AdminBulkActionResult{}, errors.New("users admin: a bulk action needs its transaction") } affected, err := run(classes.ContextWithThrottle(ctx, c.application()), tx, users) if err != nil { return pact.AdminBulkActionResult{}, err } result := pact.AdminBulkActionResult{Affected: affected} if affected == len(users) { result.Message = usersLang + name + "_selected_success" } return result, nil }, } } return []pact.AdminBulkAction{ bulk("activate", classes.ActivateUsers), bulk("deactivate", classes.DeactivateUsers), bulk("restore", classes.RestoreUsers), bulk("ban", classes.BanUsers), bulk("unban", classes.UnbanUsers), } } // throttleState reads whether one user is banned and whether they are // suspended, through the write's transaction when there is one. func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) { db := requestDB(ctx, c.db) if db == nil { return false, false, errors.New("users admin: gorm handle is missing") } bannedSet, suspendedSet, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, c.application()), db, []uint{userID}) if err != nil { return false, false, err } return bannedSet[userID], suspendedSet[userID], nil } // AdminRecordActions are the PHP preview handlers onActivate, onUnban and // onUnsuspendUser. Each is offered only while it applies; the checks only // read. There is no impersonate action and no guest action. func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction { asUser := func(record any) (*models.User, error) { user, ok := record.(*models.User) if !ok || user == nil { return nil, errors.New("users admin: action record is not a user") } return user, nil } action := func(name, label, confirm, success string, applies func(ctx context.Context, user *models.User) (bool, error), run func(ctx context.Context, tx *gorm.DB, user *models.User) error, ) pact.AdminRecordAction { return pact.AdminRecordAction{ Name: name, Label: usersLang + label, Confirm: usersLang + confirm, Permissions: []string{PermissionAccessUsers}, Applies: func(ctx context.Context, record any) (bool, error) { user, err := asUser(record) if err != nil { return false, err } return applies(ctx, user) }, Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { user, err := asUser(in.Record) if err != nil { return pact.AdminRecordActionResult{}, err } tx, ok := cabana.TxFromContext(ctx) if !ok { return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction") } if err := run(ctx, tx, user); err != nil { return pact.AdminRecordActionResult{}, err } return pact.AdminRecordActionResult{Message: usersLang + success}, nil }, } } return []pact.AdminRecordAction{ action("activate", "activate_user", "activate_confirm", "activated_success", func(_ context.Context, user *models.User) (bool, error) { return !user.IsActivated, nil }, func(ctx context.Context, tx *gorm.DB, user *models.User) error { _, err := classes.ActivateUsers(ctx, tx, []*models.User{user}) return err }), action("unban", "unban_user", "unban_confirm", "unbanned_success", func(ctx context.Context, user *models.User) (bool, error) { banned, _, err := c.throttleState(ctx, user.ID) return banned, err }, func(ctx context.Context, tx *gorm.DB, user *models.User) error { _, err := classes.UnbanUsers(ctx, tx, []*models.User{user}) return err }), action("unsuspend", "unsuspend_user", "unsuspend_confirm", "unsuspend_success", func(ctx context.Context, user *models.User) (bool, error) { _, suspended, err := c.throttleState(ctx, user.ID) return suspended, err }, func(ctx context.Context, tx *gorm.DB, user *models.User) error { return classes.UnsuspendUser(ctx, tx, user.ID) }), } } // usersHintView is the view model of controllers/users/_hint.htm, the status // callout above the preview: the callout kind (danger or warning) and the // phrase keys of its title and text. It is empty when no state applies, and // the template then renders nothing. type usersHintView struct { Kind string Title string Text string } // PartialData serves the preview header partial `hint`: one callout by // precedence banned, deactivated, not activated, suspended (the PHP // _hint_banned, _hint_trashed and _hint_activate partials plus a suspension // hint). Any other partial name is an error. func (c usersAdminController) PartialData(ctx context.Context, name string, record any) (any, error) { if name != "hint" { return nil, fmt.Errorf("users admin: unknown partial %q", name) } user, ok := record.(*models.User) if !ok || user == nil { return usersHintView{}, nil } banned, suspended, err := c.throttleState(ctx, user.ID) if err != nil { return nil, err } switch { case banned: return usersHintView{Kind: "danger", Title: usersLang + "banned_hint_title", Text: usersLang + "banned_hint_desc"}, nil case user.DeletedAt.Valid: return usersHintView{Kind: "danger", Title: usersLang + "trashed_hint_title", Text: usersLang + "trashed_hint_desc"}, nil case !user.IsActivated: return usersHintView{Kind: "warning", Title: usersLang + "activate_warning_title", Text: usersLang + "activate_warning_desc"}, nil case suspended: return usersHintView{Kind: "warning", Title: usersLang + "suspended_hint_title", Text: usersLang + "suspended_hint_desc"}, nil } return usersHintView{}, nil }