mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
Merge commit 'cf83a60'
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user