package controllers import ( "context" "errors" "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" "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) }