// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini package main import ( "bufio" "context" "database/sql" "errors" "fmt" "io" "log/slog" "math" "strconv" "strings" "text/tabwriter" "time" ) // tokenListHeader holds the header cells of `token list` var tokenListHeader = []string{"TOKEN", "DESCRIPTION", "ACTIVE", "CREATED_AT", "DELETED_AT"} // auditListHeader holds the header cells of `audit` var auditListHeader = []string{"TIME", "TOKEN", "ORIGIN", "OPERATION"} // tokenListAll is the only argument accepted by `token list` (case-sensitive) const tokenListAll = "all" // auditAll is the only word accepted by `audit` (case-sensitive): print every row const auditAll = "all" // defaultAuditLimit is the number of rows printed by `audit` without arguments const defaultAuditLimit = 10 // auditUnits maps the window unit letters (lowercase only) to their duration var auditUnits = map[byte]time.Duration{ 's': time.Second, 'm': time.Minute, 'h': time.Hour, 'd': 24 * time.Hour, 'w': 7 * 24 * time.Hour, } // auditSelection is a parsed audit argument: exactly one of all, limit > 0 and window > 0 holds type auditSelection struct { all bool // print every row limit int // latest rows to print window time.Duration // print the rows with time >= now - window } // tokenCommand is a parsed token subcommand ready to run against a store type tokenCommand func(ctx context.Context, store *TokenStore) error // parseTokenCommand validates the arguments following "token" without touching the database func (a *app) parseTokenCommand(args []string) (string, tokenCommand, error) { if len(args) == 0 { return "", nil, usageErrorf("missing token subcommand") } switch args[0] { case "list": if len(args) > 2 { return "", nil, usageErrorf("token list takes at most one argument") } if len(args) == 2 && args[1] != tokenListAll { return "", nil, usageErrorf("unknown token list argument") } activeOnly := len(args) == 1 return "token list", func(ctx context.Context, store *TokenStore) error { return a.tokenList(ctx, store, activeOnly) }, nil case "new": if len(args) > 2 { return "", nil, usageErrorf("token new takes at most one argument") } description := "" if len(args) == 2 { description = args[1] } return "token new", func(ctx context.Context, store *TokenStore) error { return a.tokenNew(ctx, store, description) }, nil case "delete": if len(args) != 2 { return "", nil, usageErrorf("token delete requires exactly one argument") } value := args[1] return "token delete", func(ctx context.Context, store *TokenStore) error { return a.tokenDelete(ctx, store, value) }, nil default: return "", nil, usageErrorf("unknown token subcommand") } } // runTokenCommand parses and executes a token subcommand func (a *app) runTokenCommand(ctx context.Context, cfg Config, log *slog.Logger, args []string) (err error) { defer traceFunc(ctx, log, "app.runTokenCommand")() // Parse first: usage errors never touch the database name, cmd, err := a.parseTokenCommand(args) if err != nil { return err } log.DebugContext(ctx, "cli dispatch", "command", name) return a.withDatabase(ctx, cfg, log, name, func(db *sql.DB) error { return cmd(ctx, NewTokenStore(db, log, a.now, a.generateToken)) }) } // withDatabase opens the database, runs the named CLI command against it and closes it func (a *app) withDatabase(ctx context.Context, cfg Config, log *slog.Logger, name string, cmd func(db *sql.DB) error) (err error) { // Open the database db, err := OpenDatabase(ctx, cfg.DBPath, log) if err != nil { return err } defer closeDatabase(ctx, db, log, &err) // Execute err = cmd(db) log.DebugContext(ctx, "cli command finished", "command", name, "ok", err == nil) return err } // writeTable prints the header and the rows as a column-aligned table // Every column but the last is padded with spaces to its widest cell (in runes) plus 2; lines carry no trailing whitespace func writeTable(out io.Writer, header []string, rows [][]string) error { w := bufio.NewWriter(out) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, strings.Join(header, "\t")) for _, row := range rows { fmt.Fprintln(tw, strings.Join(row, "\t")) } // Write errors are reported once, keeping the first one err := tw.Flush() if ferr := w.Flush(); err == nil { err = ferr } if err != nil { return fmt.Errorf("write output: %w", err) } return nil } // formatTokenRow renders the cells of one `token list` line // The description is Go-quoted so every row is exactly one line whatever its content func formatTokenRow(tok Token) []string { deletedAt := "-" if tok.DeletedAt != nil { deletedAt = formatTime(*tok.DeletedAt) } return []string{tok.Token, strconv.Quote(tok.Description), strconv.FormatBool(tok.Active), formatTime(tok.CreatedAt), deletedAt} } // tokenList prints the header and the active tokens, or every token when activeOnly is false func (a *app) tokenList(ctx context.Context, store *TokenStore, activeOnly bool) error { defer traceFunc(ctx, store.log, "app.tokenList")() tokens, err := store.ListTokens(ctx, activeOnly) if err != nil { return err } rows := make([][]string, 0, len(tokens)) for _, tok := range tokens { rows = append(rows, formatTokenRow(tok)) } return writeTable(a.stdout, tokenListHeader, rows) } // tokenNew creates a token and prints it func (a *app) tokenNew(ctx context.Context, store *TokenStore, description string) error { defer traceFunc(ctx, store.log, "app.tokenNew")() tok, err := store.CreateToken(ctx, description) if err != nil { return err } store.log.DebugContext(ctx, "token created", "token_id", tokenID(tok.Token)) if _, err := fmt.Fprintln(a.stdout, tok.Token); err != nil { return fmt.Errorf("write output: %w", err) } return nil } // tokenDelete deactivates a token; nothing is printed on success func (a *app) tokenDelete(ctx context.Context, store *TokenStore, value string) error { defer traceFunc(ctx, store.log, "app.tokenDelete")() if err := store.DeactivateToken(ctx, value); err != nil { return err } store.log.DebugContext(ctx, "token deactivated", "token_id", tokenID(value)) return nil } // parseAuditArgs validates the arguments following "audit" without touching the database // Accepted: nothing (latest 10 rows), all (every row), (latest n rows), (rows of the last n units) // all is exact and case-sensitive; n is ASCII decimal digits only (leading zeros allowed) and at least 1; the argument is never echoed func parseAuditArgs(args []string) (auditSelection, error) { switch len(args) { case 0: return auditSelection{limit: defaultAuditLimit}, nil case 1: default: return auditSelection{}, usageErrorf("audit takes at most one argument") } if args[0] == auditAll { return auditSelection{all: true}, nil } // Split an optional trailing unit letter digits, unit := args[0], time.Duration(0) if n := len(digits); n > 0 { if u, ok := auditUnits[digits[n-1]]; ok { digits, unit = digits[:n-1], u } } // The count must be a positive decimal integer that fits an int n, err := parsePositiveDecimal(digits) if err != nil { return auditSelection{}, usageErrorf("invalid audit argument") } if unit == 0 { return auditSelection{limit: n}, nil } // The window must fit a time.Duration if int64(n) > math.MaxInt64/int64(unit) { return auditSelection{}, usageErrorf("invalid audit argument") } return auditSelection{window: time.Duration(n) * unit}, nil } // parsePositiveDecimal accepts only ASCII decimal digits (no sign, no whitespace) with a value >= 1 that fits an int func parsePositiveDecimal(s string) (int, error) { if s == "" { return 0, errors.New("empty number") } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return 0, errors.New("not a decimal integer") } } n, err := strconv.Atoi(s) if err != nil { return 0, err } if n < 1 { return 0, errors.New("number must be at least 1") } return n, nil } // runAuditCommand parses and executes the audit command func (a *app) runAuditCommand(ctx context.Context, cfg Config, log *slog.Logger, args []string) error { defer traceFunc(ctx, log, "app.runAuditCommand")() // Parse first: usage errors never touch the database sel, err := parseAuditArgs(args) if err != nil { return err } switch { case sel.all: log.DebugContext(ctx, "cli dispatch", "command", "audit", "all", true) case sel.window > 0: log.DebugContext(ctx, "cli dispatch", "command", "audit", "window", sel.window) default: log.DebugContext(ctx, "cli dispatch", "command", "audit", "limit", sel.limit) } return a.withDatabase(ctx, cfg, log, "audit", func(db *sql.DB) error { return a.auditList(ctx, NewAuditStore(db, log, a.now), sel) }) } // formatOperationRow renders the cells of one `audit` line // Time and token are printed exactly as stored; origin and operation are written by the server and contain no tabs or newlines func formatOperationRow(op Operation) []string { return []string{op.TimeText, op.Token, op.Origin, op.Operation} } // auditList prints the header and the selected audit rows, oldest first func (a *app) auditList(ctx context.Context, store *AuditStore, sel auditSelection) error { defer traceFunc(ctx, store.log, "app.auditList")() // Resolve the selection against the clock; the zero query selects every row var q OperationQuery switch { case sel.all: case sel.window > 0: since := a.now().Add(-sel.window) q.Since = &since store.log.DebugContext(ctx, "audit window resolved", "since", formatTime(since)) default: q.Limit = sel.limit } ops, err := store.ListOperations(ctx, q) if err != nil { return err } store.log.DebugContext(ctx, "audit rows listed", "rows", len(ops)) rows := make([][]string, 0, len(ops)) for _, op := range ops { rows = append(rows, formatOperationRow(op)) } return writeTable(a.stdout, auditListHeader, rows) }