package cabana import ( "context" "errors" "fmt" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" ) func tokenRuntimeCommands(app *backpack.App) []bonfire.Command { return []bonfire.Command{ { Name: "admin:token:create", Description: "Create a personal API token for a backend administrator", Args: []bonfire.Arg{{ Name: "identifier", Description: "Login or email", Required: true, }}, Flags: []bonfire.Flag{ {Name: "name", Description: "Token name"}, {Name: "scope", Description: "Scope name (repeatable)", Repeatable: true}, {Name: "expires", Description: "Expiry: 30d, 90d, 365d, never, or YYYY-MM-DD", Default: "90d"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenCreate(ctx, app, in, out) }, }, { Name: "admin:token:list", Description: "List personal API tokens for a backend administrator (or all)", Args: []bonfire.Arg{{ Name: "identifier", Description: "Login or email (optional)", }}, Flags: []bonfire.Flag{ {Name: "revoked", Description: "Include revoked tokens", Bare: true}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenList(ctx, app, in, out) }, }, { Name: "admin:token:revoke", Description: "Revoke a personal API token by id", Args: []bonfire.Arg{{ Name: "id", Description: "Token id", Required: true, }}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenRevoke(ctx, app, in, out) }, }, { Name: "admin:token:scopes", Description: "List available personal API token scopes", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenScopes(ctx, app, in, out) }, }, } } func tokenCatalogForApp(app *backpack.App) (*Registry, error) { plugins := []party.Plugin{} if app != nil { if activated, ok := app.Lookup[party.Activated](); ok && activated != nil { plugins = []party.Plugin(activated) } } return compileTokenCatalog(plugins) } func adminTokenCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { identifier := argValue(in, "identifier") if identifier == "" { return errors.New("cabana: an identifier is required") } name := strings.TrimSpace(flagValue(in, "name")) expires := strings.TrimSpace(flagValue(in, "expires")) scopes := in.Flags("scope") if len(scopes) == 0 { if s := flagValue(in, "scope"); s != "" { scopes = []string{s} } } catalog, err := tokenCatalogForApp(app) if err != nil { return err } return withAdminDB(ctx, app, func(gdb *gorm.DB) error { user, found, err := findBackendLogin(gdb.WithContext(ctx), identifier) if err != nil { return err } if !found { return fmt.Errorf("cabana: admin %q not found", identifier) } if !user.IsActivated || user.DeletedAt.Valid { return fmt.Errorf("cabana: admin %q is deactivated or deleted", identifier) } principal, err := (BackendUsers{DB: gdb, Registry: catalog}).FindByID(ctx, user.ID) if err != nil || principal == nil { return fmt.Errorf("cabana: could not load admin %q", identifier) } plain, _, details, err := issueAPIToken(gdb, catalog, principal, name, expires, scopes, time.Now().UTC(), true) if err != nil { return err } if details != nil { return fmt.Errorf("cabana: %s", firstDetail(details)) } out.Println(plain) return nil }) } func adminTokenList(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { identifier := argValue(in, "identifier") showRevoked := flagValue(in, "revoked") == "true" return withAdminDB(ctx, app, func(gdb *gorm.DB) error { q := gdb.WithContext(ctx).Model(&BackendAPIToken{}).Order("created_at DESC").Order("id DESC") if identifier != "" { user, found, err := findBackendLogin(gdb.WithContext(ctx), identifier) if err != nil { return err } if !found { return fmt.Errorf("cabana: admin %q not found", identifier) } q = q.Where("backend_user_id = ?", user.ID) } if !showRevoked { q = q.Where("revoked_at IS NULL") } var rows []BackendAPIToken if err := q.Find(&rows).Error; err != nil { return err } headers := []string{"id", "login", "name", "scopes", "hint", "created", "last used", "expires", "revoked"} table := make([][]string, 0, len(rows)) for i := range rows { row := &rows[i] var login string _ = gdb.WithContext(ctx).Raw(`SELECT login FROM backend_users WHERE id = ?`, row.BackendUserID).Scan(&login).Error scopes := "" if row.Scopes.Valid { scopes = strings.Join(row.Scopes.Get(), ",") } table = append(table, []string{ strconv.FormatUint(uint64(row.ID), 10), login, row.Name, scopes, APITokenPrefix + "…" + row.TokenHint, formatTime(row.CreatedAt), formatTimePtr(row.LastUsedAt), formatTimePtr(row.ExpiresAt), formatTimePtr(row.RevokedAt), }) } out.Table(headers, table) return nil }) } func adminTokenRevoke(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { raw := argValue(in, "id") id, err := strconv.ParseUint(raw, 10, 64) if err != nil || id == 0 { return errors.New("cabana: a token id is required") } return withAdminDB(ctx, app, func(gdb *gorm.DB) error { var row BackendAPIToken err := gdb.WithContext(ctx).First(&row, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("cabana: token %d not found", id) } if err != nil { return err } if row.RevokedAt != nil { out.Info(fmt.Sprintf("token %d was already revoked", id)) return nil } now := time.Now().UTC() if err := gdb.WithContext(ctx).Model(&row).Updates(map[string]any{ "revoked_at": now, "revoked_by": nil, }).Error; err != nil { return err } out.Success(fmt.Sprintf("revoked token %d", id)) return nil }) } func adminTokenScopes(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { catalog, err := tokenCatalogForApp(app) if err != nil { return err } tr, _ := app.Lookup[*phrasebook.Translator]() headers := []string{"name", "label", "read-only", "permissions"} rows := make([][]string, 0, len(catalog.tokenScopes.ordered)) for _, scope := range catalog.tokenScopes.ordered { label := scope.Label if strings.Contains(label, "::") && tr != nil && tr.Has(label) { if s := tr.Get(ctx, label, nil); s != "" { label = s } } readOnly := "yes" for m := range scope.Methods { if m != "GET" && m != "HEAD" { readOnly = "no" break } } if scope.Full && scope.Name == scopeAll { readOnly = "no" } perms := strings.Join(scope.Permissions, ",") if scope.Full { perms = "(full)" } rows = append(rows, []string{scope.Name, label, readOnly, perms}) } out.Table(headers, rows) return nil } func argValue(in bonfire.Input, name string) string { if in == nil { return "" } if v, ok := in.Argument(name); ok { return strings.TrimSpace(v) } if name == "identifier" && len(in.Args()) > 0 { return strings.TrimSpace(in.Args()[0]) } if name == "id" && len(in.Args()) > 0 { return strings.TrimSpace(in.Args()[0]) } return "" } func firstDetail(details map[string]any) string { for _, raw := range details { if keys, ok := raw.([]string); ok && len(keys) > 0 { key := keys[0] if name, ok := strings.CutPrefix(key, "api_tokens.scope_unavailable|"); ok { return fmt.Sprintf("scope %q is not available", name) } return key } } return "validation failed" } func formatTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) } func formatTimePtr(t *time.Time) string { if t == nil { return "" } return formatTime(*t) }