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 }