package console import ( "context" "fmt" "strconv" "strings" "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/bonfire" "gorm.io/gorm" ) // APITokenCommands are the application-wide token create/list/revoke // commands. Domain plugins deliberately do not mint their own credentials. // withDB is the plugin's database accessor so the commands work both during // serve and as one-shot CLI runs that open Postgres from config. func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm.DB) error) error) []bonfire.Command { open := func(ctx context.Context, fn func(*gorm.DB) error) error { if withDB == nil { return fmt.Errorf("golem15.user: database is not configured") } return withDB(ctx, fn) } return []bonfire.Command{ { Name: "user:token:create", Description: "Create one API token usable by application plugins", Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}}, Flags: []bonfire.Flag{ {Name: "name", Description: "What the token is for", Default: "api"}, {Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"}, {Name: "expires", Description: "RFC3339 expiry; empty means no expiry"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { email, _ := in.Argument("email") name, _ := in.Flag("name") rawScopes, _ := in.Flag("scopes") scopes := []string{} for _, scope := range strings.Split(rawScopes, ",") { if scope = strings.TrimSpace(scope); scope != "" { scopes = append(scopes, scope) } } var expiresAt *time.Time if raw, _ := in.Flag("expires"); strings.TrimSpace(raw) != "" { parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw)) if err != nil { return fmt.Errorf("user:token:create: --expires: %w", err) } expiresAt = &parsed } return open(ctx, func(db *gorm.DB) error { user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email)) if err != nil { return err } if user == nil { return fmt.Errorf("user:token:create: no user with the e-mail %q", email) } prefix, err := classes.APITokenPrefix(app) if err != nil { return err } secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil) if err != nil { return fmt.Errorf("user:token:create: %w", err) } if err := db.Create(token).Error; err != nil { return fmt.Errorf("user:token:create: %w", err) } out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ",")) out.Println(secret) out.Success("user:token:create: store the secret now; it is not shown again") return nil }) }, }, { Name: "user:token:list", Description: "List API tokens", Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error { return open(ctx, func(db *gorm.DB) error { var tokens []models.APIToken if err := db.Order("id").Find(&tokens).Error; err != nil { return fmt.Errorf("user:token:list: %w", err) } rows := make([][]string, 0, len(tokens)) for _, token := range tokens { state := "active" if token.RevokedAt != nil { state = "revoked" } else if !token.IsUsableAt(time.Now()) { state = "expired" } name := "" if token.Name != nil { name = *token.Name } rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state}) } out.Table([]string{"id", "user", "name", "scopes", "state"}, rows) return nil }) }, }, { Name: "user:token:revoke", Description: "Revoke an API token", Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { raw, _ := in.Argument("id") id, err := strconv.ParseUint(raw, 10, 64) if err != nil || id == 0 { return fmt.Errorf("user:token:revoke: %q is not a token id", raw) } return open(ctx, func(db *gorm.DB) error { res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now()) if res.Error != nil { return fmt.Errorf("user:token:revoke: %w", res.Error) } if res.RowsAffected == 0 { return fmt.Errorf("user:token:revoke: no active token with id %d", id) } out.Success(fmt.Sprintf("user:token:revoke: token %d revoked", id)) return nil }) }, }, } }