Files
summercms/modules/cabana/account_tokens.go
Jakub Zych ffdb5d00ca feat(16.3-01): account token API with OpenAPI and phrase keys
- Add list/create/revoke/scopes handlers, validation, and soft revoke
- Regenerate admin OpenAPI and TS types; inventory and conformance cases
- Add server-side api_tokens phrase keys (en/pl)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 01:51:11 +02:00

306 lines
9.4 KiB
Go

package cabana
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
// AdminAPITokenScope is one scope on a token or in the picker list.
type AdminAPITokenScope struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
ReadOnly bool `json:"read_only"`
Known bool `json:"known"`
}
// AdminAPIToken is one personal API token row in list/revoke responses.
type AdminAPIToken struct {
ID uint `json:"id"`
Name string `json:"name"`
Hint string `json:"hint"`
Scopes []AdminAPITokenScope `json:"scopes"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
Expired bool `json:"expired"`
}
// AdminAPITokenCreated is the create response: every AdminAPIToken field plus
// the plaintext token (shown exactly once).
type AdminAPITokenCreated struct {
AdminAPIToken
Token string `json:"token"`
}
// AdminAPITokenRequest is the POST /account/tokens body.
type AdminAPITokenRequest struct {
Name string `json:"name"`
Expires string `json:"expires"`
Scopes []string `json:"scopes"`
}
var allowedTokenCreateKeys = map[string]bool{"name": true, "expires": true, "scopes": true}
func (s *service) accountTokensList(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
showRevoked := false
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("revoked"))) {
case "1", "true":
showRevoked = true
}
q := db.WithContext(r.Context()).Where("backend_user_id = ?", principal.ID)
if !showRevoked {
q = q.Where("revoked_at IS NULL")
}
var rows []BackendAPIToken
if err := q.Order("created_at DESC").Order("id DESC").Find(&rows).Error; err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account tokens list", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
tr := s.translator()
now := time.Now().UTC()
out := make([]AdminAPIToken, 0, len(rows))
for i := range rows {
out = append(out, mapAdminAPIToken(r.Context(), tr, s.reg, &rows[i], now))
}
WriteData(w, http.StatusOK, out, nil)
}
func (s *service) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
body, err := s.decodeCappedObject(w, r)
if err != nil {
writeRelationError(w, err)
return
}
tr := s.translator()
if details := refusedTokenCreateKeys(r.Context(), tr, body); len(details) > 0 {
writeCRUDError(w, &ValidationError{Details: details})
return
}
name, _ := body["name"].(string)
expires, _ := body["expires"].(string)
var scopes []string
switch v := body["scopes"].(type) {
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
scopes = append(scopes, s)
}
}
case []string:
scopes = v
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token create without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
now := time.Now().UTC()
plain, row, details, err := issueAPIToken(db, s.reg, principal, name, expires, scopes, now, false)
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token create", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
if details != nil {
writeCRUDError(w, &ValidationError{Details: translateTokenDetails(r.Context(), tr, details)})
return
}
created := AdminAPITokenCreated{
AdminAPIToken: mapAdminAPIToken(r.Context(), tr, s.reg, row, now),
Token: plain,
}
WriteData(w, http.StatusCreated, created, nil)
}
func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil || id == 0 {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
revokedBy := principal.ID
row, notFound, err := revokeAPIToken(db, principal.ID, uint(id), &revokedBy)
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: account token revoke", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
if notFound {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
WriteData(w, http.StatusOK, mapAdminAPIToken(r.Context(), s.translator(), s.reg, row, time.Now().UTC()), nil)
}
func (s *service) accountTokenScopes(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
tr := s.translator()
var out []AdminAPITokenScope
if s.reg != nil && s.reg.tokenScopes != nil {
for _, scope := range s.reg.tokenScopes.ordered {
if !scopeOverlaps(s.reg, principal, scope.Name) {
continue
}
out = append(out, mapCompiledScope(r.Context(), tr, scope))
}
}
WriteData(w, http.StatusOK, out, nil)
}
func refusedTokenCreateKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
details := map[string]any{}
for key := range body {
if !allowedTokenCreateKeys[key] {
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed.")}
}
}
return details
}
func translateTokenDetails(ctx context.Context, tr *phrasebook.Translator, details map[string]any) map[string]any {
out := map[string]any{}
for field, raw := range details {
keys, _ := raw.([]string)
msgs := make([]string, 0, len(keys))
for _, key := range keys {
if name, ok := strings.CutPrefix(key, "api_tokens.scope_unavailable|"); ok {
msgs = append(msgs, backendMessageParams(ctx, tr, "api_tokens.scope_unavailable", "The scope :name is not available to you.", map[string]string{"name": name}))
continue
}
switch key {
case "api_tokens.name_required":
msgs = append(msgs, backendMessage(ctx, tr, key, "Enter a name for the token."))
case "api_tokens.name_too_long":
msgs = append(msgs, backendMessage(ctx, tr, key, "The name may have at most 100 characters."))
case "api_tokens.expires_invalid":
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose a valid expiry."))
case "api_tokens.scopes_required":
msgs = append(msgs, backendMessage(ctx, tr, key, "Choose at least one scope."))
default:
msgs = append(msgs, backendMessage(ctx, tr, key, key))
}
}
out[field] = msgs
}
return out
}
func backendMessageParams(ctx context.Context, tr *phrasebook.Translator, key, fallback string, params map[string]string) string {
full := "backend::lang." + key
if tr != nil && tr.Has(full) {
if s := tr.Get(ctx, full, params); s != "" && s != full {
return s
}
}
msg := fallback
for k, v := range params {
msg = strings.ReplaceAll(msg, ":"+k, v)
}
return msg
}
func mapAdminAPIToken(ctx context.Context, tr *phrasebook.Translator, reg *Registry, row *BackendAPIToken, now time.Time) AdminAPIToken {
stored := []string{}
if row.Scopes.Valid {
stored = row.Scopes.Get()
}
scopes := make([]AdminAPITokenScope, 0, len(stored))
for _, name := range stored {
scopes = append(scopes, mapStoredScope(ctx, tr, reg, name))
}
expired := row.ExpiresAt != nil && !row.ExpiresAt.After(now) && row.RevokedAt == nil
return AdminAPIToken{
ID: row.ID,
Name: row.Name,
Hint: row.TokenHint,
Scopes: scopes,
CreatedAt: row.CreatedAt,
LastUsedAt: row.LastUsedAt,
ExpiresAt: row.ExpiresAt,
RevokedAt: row.RevokedAt,
Expired: expired,
}
}
func mapStoredScope(ctx context.Context, tr *phrasebook.Translator, reg *Registry, name string) AdminAPITokenScope {
if reg != nil && reg.tokenScopes != nil {
if scope, ok := reg.tokenScopes.byName[name]; ok {
return mapCompiledScope(ctx, tr, scope)
}
}
return AdminAPITokenScope{Name: name, Label: name, Description: "", ReadOnly: false, Known: false}
}
func mapCompiledScope(ctx context.Context, tr *phrasebook.Translator, scope *compiledTokenScope) AdminAPITokenScope {
label := scope.Label
desc := scope.Description
if strings.Contains(label, "::") {
label = resolveScopePhrase(ctx, tr, label, label)
}
if strings.Contains(desc, "::") {
desc = resolveScopePhrase(ctx, tr, desc, desc)
}
readOnly := true
for m := range scope.Methods {
if m != "GET" && m != "HEAD" {
readOnly = false
break
}
}
if scope.Full && scope.Name == scopeAll {
readOnly = false
}
return AdminAPITokenScope{
Name: scope.Name,
Label: label,
Description: desc,
ReadOnly: readOnly,
Known: true,
}
}
func resolveScopePhrase(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
if tr != nil && tr.Has(key) {
if s := tr.Get(ctx, key, nil); s != "" && s != key {
return s
}
}
return fallback
}