package cabana import ( "context" "errors" "fmt" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) // RuntimeCommands returns the operator commands for backend administrators. func RuntimeCommands(app *backpack.App) []bonfire.Command { return []bonfire.Command{ { Name: "admin:create", Description: "Create an activated backend administrator", Flags: []bonfire.Flag{ {Name: "email", Description: "Admin email"}, {Name: "password", Description: "Admin password"}, {Name: "login", Description: "Login; defaults to the lower-cased email"}, {Name: "superuser", Description: "Grant superuser", Bare: true}, {Name: "role", Description: "Role code"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminCreate(ctx, app, in, out) }, }, { Name: "admin:reset-password", Description: "Reset a backend administrator password and revoke existing tokens", Args: []bonfire.Arg{{ Name: "identifier", Description: "Login or email", Required: true, }}, Flags: []bonfire.Flag{{ Name: "password", Description: "New password", }}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminResetPassword(ctx, app, in, out) }, }, } } func adminCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { email := strings.ToLower(strings.TrimSpace(flagValue(in, "email"))) password := flagValue(in, "password") if email == "" || !strings.Contains(email, "@") || strings.TrimSpace(password) == "" { return errors.New("cabana: email and password are required") } login := strings.TrimSpace(flagValue(in, "login")) if login == "" { login = email } roleCode := strings.TrimSpace(flagValue(in, "role")) superuser := flagValue(in, "superuser") == "true" return withAdminDB(ctx, app, func(gdb *gorm.DB) error { return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { roleID, err := roleIDByCode(tx, roleCode) if err != nil { return err } var existing int64 if err := tx.Model(&BackendUser{}).Where("login = ? OR lower(email) = ?", login, email).Count(&existing).Error; err != nil { return err } if existing > 0 { return errors.New("cabana: admin already exists") } hash, err := bouncer.HashPassword(adminBcryptCost(app), password) if err != nil { return err } now := time.Now().UTC() user := BackendUser{ Login: login, Email: email, Password: hash, IsActivated: true, IsSuperuser: superuser, RoleID: roleID, ActivatedAt: &now, } if err := tx.Create(&user).Error; err != nil { return err } out.Success(fmt.Sprintf("created admin %s <%s>", user.Login, user.Email)) return nil }) }) } func adminResetPassword(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { identifier := "" if value, ok := in.Argument("identifier"); ok { identifier = strings.TrimSpace(value) } if identifier == "" && len(in.Args()) > 0 { identifier = strings.TrimSpace(in.Args()[0]) } password := flagValue(in, "password") if identifier == "" || strings.TrimSpace(password) == "" { return errors.New("cabana: identifier and password are required") } return withAdminDB(ctx, app, func(gdb *gorm.DB) error { return gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var users []BackendUser err := tx.Where("login = ? OR lower(email) = ?", identifier, strings.ToLower(identifier)).Find(&users).Error if err != nil { return err } if len(users) == 0 { return errors.New("cabana: admin not found") } if len(users) > 1 { return errors.New("cabana: ambiguous admin") } hash, err := bouncer.HashPassword(adminBcryptCost(app), password) if err != nil { return err } cutoff := time.Now().UTC().Add(time.Second) if err := tx.Model(&BackendUser{}).Where("id = ?", users[0].ID).Updates(map[string]any{ "password": hash, "tokens_valid_after": cutoff, }).Error; err != nil { return err } out.Success(fmt.Sprintf("reset password for %s", users[0].Login)) return nil }) }) } func roleIDByCode(tx *gorm.DB, code string) (*uint, error) { if code == "" { return nil, nil } var ids []uint if err := tx.Model(&BackendUserRole{}).Where("code = ?", code).Pluck("id", &ids).Error; err != nil { return nil, err } switch len(ids) { case 0: return nil, fmt.Errorf("cabana: unknown role %q", code) case 1: return &ids[0], nil default: return nil, fmt.Errorf("cabana: ambiguous role %q", code) } } func flagValue(in bonfire.Input, name string) string { if in == nil { return "" } value, _ := in.Flag(name) return value } func withAdminDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error { if app == nil { return errors.New("cabana: app is nil") } if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil { return fn(gdb) } sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app) if err != nil { return err } defer sqlDB.Close() if err := lagoon.Publish(app, sqlDB, gdb); err != nil { return err } return fn(gdb) }