Merge commit 'cf83a60'

This commit is contained in:
xarmian
2026-03-28 14:16:08 +00:00
7 changed files with 376 additions and 1 deletions
+28
View File
@@ -0,0 +1,28 @@
package models
import "time"
// APIToken represents a stored API token (without the secret).
type APIToken struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Scopes string `json:"scopes"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// APITokenCreate is the input for creating a new API token.
type APITokenCreate struct {
Name string `json:"name"`
Scopes string `json:"scopes,omitempty"`
}
// APITokenWithSecret is returned only on creation and includes the
// plaintext token. The token is never stored and cannot be retrieved again.
type APITokenWithSecret struct {
APIToken
Token string `json:"token"` // Only returned once
}
+77
View File
@@ -0,0 +1,77 @@
package server
import (
"database/sql"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/xarmian/pad/internal/models"
)
// handleCreateToken creates a new API token for a workspace.
// The plaintext token is returned only in this response.
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
var input models.APITokenCreate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if input.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "name is required")
return
}
token, err := s.store.CreateAPIToken(workspaceID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
writeJSON(w, http.StatusCreated, token)
}
// handleListTokens returns all API tokens for a workspace (without secrets).
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
tokens, err := s.store.ListAPITokens(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
if tokens == nil {
tokens = []models.APIToken{}
}
writeJSON(w, http.StatusOK, tokens)
}
// handleDeleteToken revokes an API token by ID.
func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
_, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
tokenID := chi.URLParam(r, "tokenID")
if err := s.store.DeleteAPIToken(tokenID); err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "not_found", "Token not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"context"
"net/http"
"strings"
)
type contextKey string
const (
// ctxTokenWorkspaceID is set when a valid API token is present.
ctxTokenWorkspaceID contextKey = "token_workspace_id"
)
// TokenAuth middleware checks for an Authorization: Bearer pad_xxx header.
// If a valid token is found, the associated workspace ID is stored in the
// request context. If no token header is present the request passes through
// unchanged (existing localhost behaviour). Invalid or expired tokens
// receive a 401 response.
func (s *Server) TokenAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" {
// No token provided — allow through (localhost access).
next.ServeHTTP(w, r)
return
}
// Expect "Bearer pad_<64 hex chars>"
if !strings.HasPrefix(auth, "Bearer ") {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid authorization header format")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
token = strings.TrimSpace(token)
if !strings.HasPrefix(token, "pad_") || len(token) != 68 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid token format")
return
}
apiToken, err := s.store.ValidateToken(token)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Token validation failed")
return
}
if apiToken == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token")
return
}
// Store workspace ID from the token in request context.
ctx := context.WithValue(r.Context(), ctxTokenWorkspaceID, apiToken.WorkspaceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// tokenWorkspaceID returns the workspace ID set by the TokenAuth middleware,
// or an empty string if no token was used.
func tokenWorkspaceID(r *http.Request) string {
v, _ := r.Context().Value(ctxTokenWorkspaceID).(string)
return v
}
+9 -1
View File
@@ -52,10 +52,11 @@ func (s *Server) setupRouter() {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:*", "http://127.0.0.1:*"},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(s.TokenAuth)
r.Use(jsonContentType)
// SSE endpoint (outside jsonContentType middleware)
@@ -166,6 +167,13 @@ func (s *Server) setupRouter() {
})
})
// API Tokens
r.Route("/tokens", func(r chi.Router) {
r.Get("/", s.handleListTokens)
r.Post("/", s.handleCreateToken)
r.Delete("/{tokenID}", s.handleDeleteToken)
})
// Dashboard (v2)
r.Get("/dashboard", s.handleGetDashboard)
})
+184
View File
@@ -0,0 +1,184 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"github.com/xarmian/pad/internal/models"
)
// CreateAPIToken generates a new API token for a workspace. The plaintext
// token is returned in the response and is never stored — only its SHA-256
// hash is persisted.
func (s *Store) CreateAPIToken(workspaceID string, input models.APITokenCreate) (*models.APITokenWithSecret, error) {
// Generate 32 random bytes → 64 hex chars
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
plaintext := "pad_" + hex.EncodeToString(raw)
prefix := plaintext[:8]
// SHA-256 hash for storage
hash := sha256.Sum256([]byte(plaintext))
tokenHash := hex.EncodeToString(hash[:])
id := newID()
ts := now()
scopes := input.Scopes
if scopes == "" {
scopes = `["*"]`
}
_, err := s.db.Exec(`
INSERT INTO api_tokens (id, workspace_id, name, token_hash, prefix, scopes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, id, workspaceID, input.Name, tokenHash, prefix, scopes, ts)
if err != nil {
return nil, fmt.Errorf("insert api token: %w", err)
}
token, err := s.getAPIToken(id)
if err != nil {
return nil, err
}
return &models.APITokenWithSecret{
APIToken: *token,
Token: plaintext,
}, nil
}
// ListAPITokens returns all API tokens for a workspace (without secrets).
func (s *Store) ListAPITokens(workspaceID string) ([]models.APIToken, error) {
rows, err := s.db.Query(`
SELECT id, workspace_id, name, prefix, scopes, expires_at, last_used_at, created_at
FROM api_tokens
WHERE workspace_id = ?
ORDER BY created_at ASC
`, workspaceID)
if err != nil {
return nil, fmt.Errorf("list api tokens: %w", err)
}
defer rows.Close()
var result []models.APIToken
for rows.Next() {
t, err := scanAPIToken(rows)
if err != nil {
return nil, err
}
result = append(result, *t)
}
return result, rows.Err()
}
// DeleteAPIToken removes an API token by ID.
func (s *Store) DeleteAPIToken(id string) error {
result, err := s.db.Exec("DELETE FROM api_tokens WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete api token: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// ValidateToken hashes the provided plaintext token, looks it up in the
// database, checks expiry, and updates last_used_at. Returns nil if the
// token is invalid or expired.
func (s *Store) ValidateToken(token string) (*models.APIToken, error) {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
var t models.APIToken
var expiresAt, lastUsedAt *string
var createdAt string
err := s.db.QueryRow(`
SELECT id, workspace_id, name, prefix, scopes, expires_at, last_used_at, created_at
FROM api_tokens
WHERE token_hash = ?
`, tokenHash).Scan(
&t.ID, &t.WorkspaceID, &t.Name, &t.Prefix, &t.Scopes,
&expiresAt, &lastUsedAt, &createdAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("validate token: %w", err)
}
t.CreatedAt = parseTime(createdAt)
t.ExpiresAt = parseTimePtr(expiresAt)
t.LastUsedAt = parseTimePtr(lastUsedAt)
// Check expiry
if t.ExpiresAt != nil && t.ExpiresAt.Before(parseTime(now())) {
return nil, nil
}
// Update last_used_at
ts := now()
_, _ = s.db.Exec("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", ts, t.ID)
return &t, nil
}
// getAPIToken retrieves a single API token by ID.
func (s *Store) getAPIToken(id string) (*models.APIToken, error) {
var t models.APIToken
var expiresAt, lastUsedAt *string
var createdAt string
err := s.db.QueryRow(`
SELECT id, workspace_id, name, prefix, scopes, expires_at, last_used_at, created_at
FROM api_tokens
WHERE id = ?
`, id).Scan(
&t.ID, &t.WorkspaceID, &t.Name, &t.Prefix, &t.Scopes,
&expiresAt, &lastUsedAt, &createdAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get api token: %w", err)
}
t.CreatedAt = parseTime(createdAt)
t.ExpiresAt = parseTimePtr(expiresAt)
t.LastUsedAt = parseTimePtr(lastUsedAt)
return &t, nil
}
// scanner is an interface satisfied by both *sql.Row and *sql.Rows.
type scanner interface {
Scan(dest ...interface{}) error
}
// scanAPIToken scans an API token from a row scanner.
func scanAPIToken(s scanner) (*models.APIToken, error) {
var t models.APIToken
var expiresAt, lastUsedAt *string
var createdAt string
if err := s.Scan(
&t.ID, &t.WorkspaceID, &t.Name, &t.Prefix, &t.Scopes,
&expiresAt, &lastUsedAt, &createdAt,
); err != nil {
return nil, fmt.Errorf("scan api token: %w", err)
}
t.CreatedAt = parseTime(createdAt)
t.ExpiresAt = parseTimePtr(expiresAt)
t.LastUsedAt = parseTimePtr(lastUsedAt)
return &t, nil
}
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
prefix TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT '["*"]',
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
);
+1
View File
@@ -72,6 +72,7 @@ func (s *Store) migrate() error {
"008_tasks_phase_field.sql",
"009_ideas_implemented_status.sql",
"010_webhooks.sql",
"011_api_tokens.sql",
}
for _, name := range migrations {