Files
sm-user-plugin/console/api_tokens.go
Jakub Zych 9986342c23 feat: console help, examples and named loggers (SummerCMS v0.4.0)
Every command gets long help and examples, descriptions in imperative
sentence case, and a warn-only TestCommandsDocumented over bonfire.CheckDocs.
The plugin logs through a diary component logger instead of the process
default logger. Requires SummerCMS v0.4.0; command names and flags unchanged.

user:token:create and user:token:list gain --json, user:backfill-handles a
progress bar; planning IDs leave the descriptions.
2026-10-21 18:03:50 +02:00

211 lines
8.0 KiB
Go

package console
import (
"context"
"encoding/json"
"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",
Long: `Mints a personal API token that acts as the user with this e-mail, for
scripts and integrations that call the application's API. --scopes limits
what it may do (read, write, ai); --expires sets an RFC3339 expiry, and
without it the token does not expire. The secret is printed once, on its own
line (also under -q), and only its hash is stored, so keep it now; with
--json the token and its details are printed as one JSON object instead.
Revoke a token with user:token:revoke and list them with user:token:list.`,
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"},
{Name: "json", Description: "Print the token and its details as JSON", Bare: true},
},
Examples: []bonfire.Example{
{Command: "user:token:create jane@example.com --name backup-script --scopes read", Description: "A read-only token for a script"},
{Command: "user:token:create jane@example.com --expires 2027-01-01T00:00:00Z", Description: "A read and write token that expires"},
{Command: "user:token:create jane@example.com --json", Description: "Print the token and its details as JSON"},
},
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)
}
if flagOn(in, "json") {
return printJSON(out, map[string]any{
"id": token.ID,
"user_id": token.UserID,
"email": user.Email,
"name": token.Name,
"token": secret,
"scopes": token.Scopes.Get(),
"expires_at": token.ExpiresAt,
})
}
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",
Long: `Lists every personal API token: its id, the user it acts as, its name, its
scopes and its state (active, expired or revoked). Secrets are never shown;
only their hashes are stored. --json prints the tokens as a JSON array with
their expiry, revocation and last use times.`,
Flags: []bonfire.Flag{
{Name: "json", Description: "Print the tokens as JSON", Bare: true},
},
Examples: []bonfire.Example{
{Command: "user:token:list", Description: "Show every token as a table"},
{Command: "user:token:list --json", Description: "Print the tokens as JSON for a script"},
},
Run: func(ctx context.Context, in 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))
list := make([]map[string]any, 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})
list = append(list, map[string]any{
"id": token.ID,
"user_id": token.UserID,
"name": token.Name,
"scopes": token.Scopes.Get(),
"state": state,
"expires_at": token.ExpiresAt,
"revoked_at": token.RevokedAt,
"last_used_at": token.LastUsedAt,
"created_at": token.CreatedAt,
})
}
if flagOn(in, "json") {
return printJSON(out, list)
}
out.Table([]string{"id", "user", "name", "scopes", "state"}, rows)
return nil
})
},
},
{
Name: "user:token:revoke", Description: "Revoke an API token",
Long: `Revokes the active API token with this id (see user:token:list): requests
that present it are refused from now on. The token row stays, marked
revoked; a token that is already revoked or does not exist is an error.`,
Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}},
Examples: []bonfire.Example{
{Command: "user:token:revoke 12", Description: "Revoke the token with id 12"},
},
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
})
},
},
}
}
// printJSON writes v as indented JSON and a newline on out: data, printed
// under -q too.
func printJSON(out bonfire.Output, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
out.Println(string(b))
return nil
}
// flagOn reads a bare flag ("true" when given).
func flagOn(in bonfire.Input, name string) bool {
v, _ := in.Flag(name)
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes":
return true
}
return false
}