package controllers import ( "context" "errors" "fmt" "strconv" "time" "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/lagoon" "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 } // FormVirtualFields are the form fields that are not columns of the form: // the password with its confirmation, and the create-only invitation // checkbox. The framework never fills or returns them; the Form hooks read // them with cabana.VirtualFieldsFromContext. func (usersAdminController) FormVirtualFields() []string { return []string{"password", "password_confirmation", "send_invite"} } // 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: a create needs a confirmed password, an update takes one only // when the administrator types it (empty means unchanged). The minimum // length is golem15.user.password.min_length. func (c usersAdminController) FormRules(_ context.Context, op string) map[string]string { length := "between:" + strconv.Itoa(c.configInt("golem15.user.password.min_length", 8)) + ",255" password := "nullable|" + length + "|confirmed" if op == "create" { password = "required|" + length + "|confirmed" } return map[string]string{ "email": "required|between:6,255|email|unique:users", "password": password, } } // configInt reads a positive integer config value, else fallback. func (c usersAdminController) configInt(key string, fallback int) int { if cfg := c.config(); cfg != nil { if n := cfg.Int(key); n > 0 { return n } } return fallback } // 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 } if err := c.requireFreeEmail(ctx, user); err != nil { return err } changed, err := c.storePassword(ctx, user) if err != nil { return err } if changed { // An admin password reset ends every session of the user, as the // user's own change-password does. now := time.Now() user.TokensValidAfter = &now } return nil } // FormBeforeCreate checks the email against deactivated users too and stores // the hash of the submitted password. A user created here starts not // activated: is_activated is never written from the form, and only the // activate actions (or the invitation link) set it. func (c usersAdminController) FormBeforeCreate(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.requireFreeEmail(ctx, user); err != nil { return err } if _, err := c.storePassword(ctx, user); err != nil { return err } user.IsActivated = false // The column defaults to true; only an account created through a social // login starts without a password of its own. user.HasSelfSetPassword = true return nil } // requireFreeEmail refuses an email another user already has, deactivated // users included: the unique rule leaves deactivated rows out, and the // database constraint does not. func (c usersAdminController) requireFreeEmail(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 taken int64 err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). Model(&models.User{}).Where("email = ? AND id <> ?", user.Email, user.ID).Count(&taken).Error if err != nil { return err } if taken == 0 { return nil } message := "The email has already been taken." if tr := appTranslator(c.application()); tr != nil { const key = "lagoon::validate.unique" if text := tr.Get(ctx, key, map[string]string{"attribute": "email"}); text != "" && text != key { message = text } } return &cabana.ValidationError{Details: map[string]any{"email": []string{message}}} } // storePassword hashes a submitted password onto the user with // golem15.user.password.bcrypt_cost and reports whether one was submitted. // An absent or empty password changes nothing. func (c usersAdminController) storePassword(ctx context.Context, user *models.User) (bool, error) { values, _ := cabana.VirtualFieldsFromContext(ctx) plain, _ := values["password"].(string) if plain == "" { return false, nil } hashed, err := bouncer.HashPassword(c.configInt("golem15.user.password.bcrypt_cost", 10), plain) if err != nil { return false, err } user.Password = hashed return true, nil } // FormAfterCreate sends the invitation when the send_invite checkbox was // set: an activation code is issued inside the create's transaction, and // exactly one golem15.user::mail.invite message with the activation link // leaves after the commit. A create that rolls back sends nothing. func (c usersAdminController) FormAfterCreate(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") } values, _ := cabana.VirtualFieldsFromContext(ctx) if invite, _ := values["send_invite"].(bool); !invite { return nil } tx, ok := cabana.TxFromContext(ctx) if !ok { return errors.New("users admin: a create needs its transaction") } code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user) if err != nil { return err } app := c.application() id, email := user.ID, user.Email vars := mailVars(displayName(user), activationLink(app, id, code), strconv.FormatUint(uint64(id), 10)+"!"+code) vars["login"], vars["Login"] = email, email hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72) vars["hours"], vars["Hours"] = hours, hours template := mailName(ctx, user, "golem15.user::mail.invite") lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { sendTemplateVars(ctx, app, template, email, vars) }) return nil } // groupsField is the form's group membership field. const groupsField = "groups" // AdminFieldRelations binds the organisation picker and the groups field. // organisation_id is a protected column, so the contract opts in to writing // it through this one field; the submitted id is still checked against the // options. groups is the users_groups pivot: a save that carries the field // replaces the user's memberships, and AdminRelationLocks decides which of // them the administrator may change. func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{ { Field: "organisation", Kind: "belongsTo", NewRelated: func() any { return &models.Organisation{} }, ForeignKey: "organisation_id", LabelColumn: "name", WritableForeignKey: true, }, { Field: groupsField, Kind: "belongsToMany", NewRelated: func() any { return &models.UserGroup{} }, NewPivot: func() any { return &models.UsersGroup{} }, ParentForeignKey: "user_id", RelatedForeignKey: "user_group_id", LabelColumn: "name", }, } } // AdminRelationLocks locks the privileged groups in the groups field for an // administrator who lacks golem15.users.manage_privileged_groups: the // framework shows them locked and answers 403, before any row is written, to // a create or update that would add or remove one. Membership of a // privileged group makes a site administrator, so golem15.users.access_users // alone must not be able to grant or revoke it. The privileged codes come // from golem15.user.privileged_groups on every call and the group ids are // read through the write's transaction when there is one. An administrator // without a principal on the context gets the locks. Every other field has // none. func (c usersAdminController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) { if field != groupsField { return cabana.RelationLock{}, nil } principal, _ := bouncer.User(ctx) if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) { return cabana.RelationLock{}, nil } db := requestDB(ctx, c.db) if db == nil { return cabana.RelationLock{}, errors.New("users admin: gorm handle is missing") } ids, err := classes.PrivilegedGroupIDs(ctx, db, classes.PrivilegedGroupCodes(c.config())) if err != nil { return cabana.RelationLock{}, err } return cabana.RelationLock{IDs: ids, Message: usersLang + "privileged_group_forbidden"}, nil } // permissionsField is the form's permission editor field. const permissionsField = "permissions" // AdminPermissionOptions offers the rows of // golem15_user_frontend_permissions, by tab and code. None is locked. func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) { if field != permissionsField { return nil, fmt.Errorf("users admin: unknown permission field %q", field) } db := requestDB(ctx, c.db) if db == nil { return nil, errors.New("users admin: gorm handle is missing") } rows, err := classes.FrontendPermissionOptions(ctx, db) if err != nil { return nil, err } options := make([]cabana.PermissionOption, 0, len(rows)) for _, row := range rows { option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab} if row.Comment != nil { option.Comment = *row.Comment } options = append(options, option) } return options, nil } // AdminPermissionValues returns the user's own stored permissions. func (usersAdminController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) { user, ok := record.(*models.User) if field != permissionsField || !ok || user == nil { return nil, fmt.Errorf("users admin: cannot read permission field %q", field) } values := make(map[string]int, len(user.Permissions)) for code, value := range user.Permissions { values[code] = value } return values, nil } // AdminSetPermissionValues puts the validated set on the user; the save // writes the row. Zero values are left out on write (PermissionSet.Value). func (usersAdminController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error { user, ok := record.(*models.User) if field != permissionsField || !ok || user == nil { return fmt.Errorf("users admin: cannot write permission field %q", field) } set := make(models.PermissionSet, len(values)) for code, value := range values { set[code] = value } user.Permissions = set 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 }