mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 17:43:26 +00:00
1d26c2b542
* feat: add workspace top bar with drag-to-reorder Replace the sidebar WorkspaceSwitcher dropdown with a dedicated top bar that provides fast workspace switching and a user menu. Desktop: - Horizontal bar above sidebar + content with workspace icons (colored first-letter circles) and names as real <a> links - Drag-and-drop reorder via svelte-dnd-action - User avatar on right with dropdown (settings, theme toggle, sign out) - "+" button to create new workspaces Mobile: - Full-width fixed bar at top when sidebar opens (above sidebar/backdrop) - Tap workspace to navigate and close sidebar - Reorder button opens full-screen vertical list with drag handles - Sidebar starts below the top bar with adjusted positioning Backend: - Migration 028: add sort_order to workspace_members (per-user ordering) - GET /workspaces now returns workspaces in user's sort order - PUT /workspaces/reorder endpoint for persisting order Sidebar simplified: - Removed WorkspaceSwitcher component, user section, theme toggle - Theme initialization moved to root layout - Cleaner footer with search, settings, and notification bell Implements IDEA-129, relates to IDEA-126. * fix: address codex review findings (P1+P2) - Remove unsupported `direction` option from svelte-dnd-action dndzone - Add Postgres migration 008 for workspace_members.sort_order - Handle sql.ErrNoRows gracefully in reorder endpoint for admins who aren't members of all workspaces - Restore mobile sign-out: add user name + logout button to sidebar footer on mobile (was only in desktop TopBar user menu)
604 lines
19 KiB
Go
604 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
|
|
"github.com/xarmian/pad/internal/email"
|
|
"github.com/xarmian/pad/internal/events"
|
|
"github.com/xarmian/pad/internal/metrics"
|
|
"github.com/xarmian/pad/internal/models"
|
|
"github.com/xarmian/pad/internal/store"
|
|
"github.com/xarmian/pad/internal/webhooks"
|
|
)
|
|
|
|
type Server struct {
|
|
store *store.Store
|
|
router *chi.Mux
|
|
routerOnce sync.Once // ensures setupRouter runs once, after all config
|
|
httpServer *http.Server // underlying HTTP server (set during ListenAndServe)
|
|
webFS fs.FS // embedded web UI static files (optional)
|
|
events events.EventBus // real-time event bus (optional)
|
|
webhooks *webhooks.Dispatcher // webhook dispatcher (optional)
|
|
email *email.Sender // transactional email sender (optional)
|
|
rateLimiters *RateLimiters // per-endpoint rate limiters
|
|
baseURL string // public base URL for generating links (e.g. invite URLs)
|
|
corsOrigins string // comma-separated CORS origins (empty = localhost defaults)
|
|
secureCookies bool // set Secure flag on cookies (for TLS deployments)
|
|
metrics *metrics.Metrics // Prometheus metrics (optional)
|
|
sseMaxConnections int // global SSE connection limit (0 = unlimited)
|
|
sseMaxPerWorkspace int // per-workspace SSE connection limit (0 = unlimited)
|
|
version string // release version (e.g. "dev", "1.2.3")
|
|
commit string // git commit hash
|
|
buildTime string // build timestamp
|
|
twoFAChallengeSecret []byte // HMAC key for 2FA challenge tokens
|
|
}
|
|
|
|
func New(s *store.Store) *Server {
|
|
return &Server{
|
|
store: s,
|
|
rateLimiters: NewRateLimiters(),
|
|
}
|
|
}
|
|
|
|
// Init2FASecret loads the 2FA challenge signing key from platform_settings.
|
|
// If no key exists (first run), a new random key is generated and persisted.
|
|
// This must be called before the server handles requests so that challenge
|
|
// tokens survive process restarts and work across multiple instances.
|
|
func (s *Server) Init2FASecret() error {
|
|
const settingKey = "2fa_challenge_secret"
|
|
|
|
existing, err := s.store.GetPlatformSetting(settingKey)
|
|
if err != nil {
|
|
return fmt.Errorf("load 2FA secret: %w", err)
|
|
}
|
|
|
|
if existing != "" {
|
|
decoded, err := base64.StdEncoding.DecodeString(existing)
|
|
if err != nil {
|
|
return fmt.Errorf("decode 2FA secret: %w", err)
|
|
}
|
|
s.twoFAChallengeSecret = decoded
|
|
return nil
|
|
}
|
|
|
|
// First run — generate and persist a new secret.
|
|
// Multiple instances may race here on a fresh database; after persisting,
|
|
// re-read the winning value so all instances converge on the same key.
|
|
secret, err := generateTwoFASecret()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoded := base64.StdEncoding.EncodeToString(secret)
|
|
if err := s.store.SetPlatformSetting(settingKey, encoded); err != nil {
|
|
return fmt.Errorf("persist 2FA secret: %w", err)
|
|
}
|
|
|
|
// Re-read to pick up whichever instance won the race (upsert may have
|
|
// been overwritten by a concurrent instance between our check and write).
|
|
final, err := s.store.GetPlatformSetting(settingKey)
|
|
if err != nil {
|
|
return fmt.Errorf("re-read 2FA secret: %w", err)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(final)
|
|
if err != nil {
|
|
return fmt.Errorf("decode 2FA secret after re-read: %w", err)
|
|
}
|
|
s.twoFAChallengeSecret = decoded
|
|
slog.Info("initialized 2FA challenge signing key")
|
|
return nil
|
|
}
|
|
|
|
// SetVersion stores the build version info for the health endpoint.
|
|
func (s *Server) SetVersion(version, commit, buildTime string) {
|
|
s.version = version
|
|
s.commit = commit
|
|
s.buildTime = buildTime
|
|
}
|
|
|
|
// SetBaseURL sets the public base URL used for generating shareable links.
|
|
func (s *Server) SetBaseURL(url string) {
|
|
s.baseURL = strings.TrimRight(url, "/")
|
|
}
|
|
|
|
// SetEventBus attaches an event bus for real-time SSE streaming.
|
|
func (s *Server) SetEventBus(bus events.EventBus) {
|
|
s.events = bus
|
|
}
|
|
|
|
// SetWebhookDispatcher attaches a webhook dispatcher for outgoing notifications.
|
|
func (s *Server) SetWebhookDispatcher(d *webhooks.Dispatcher) {
|
|
s.webhooks = d
|
|
}
|
|
|
|
// SetEmailSender attaches a transactional email sender.
|
|
func (s *Server) SetEmailSender(e *email.Sender) {
|
|
s.email = e
|
|
}
|
|
|
|
// SetCORSOrigins configures allowed CORS origins (comma-separated).
|
|
func (s *Server) SetCORSOrigins(origins string) {
|
|
s.corsOrigins = origins
|
|
}
|
|
|
|
// SetSecureCookies enables the Secure flag on all cookies.
|
|
func (s *Server) SetSecureCookies(secure bool) {
|
|
s.secureCookies = secure
|
|
}
|
|
|
|
// SetMetrics attaches Prometheus metrics to the server.
|
|
// Must be called before the first request is served.
|
|
func (s *Server) SetMetrics(m *metrics.Metrics) {
|
|
s.metrics = m
|
|
}
|
|
|
|
// SetSSELimits configures global and per-workspace SSE connection limits.
|
|
// A value of 0 means unlimited.
|
|
func (s *Server) SetSSELimits(global, perWorkspace int) {
|
|
s.sseMaxConnections = global
|
|
s.sseMaxPerWorkspace = perWorkspace
|
|
}
|
|
|
|
// reconfigureEmail reads email settings from the platform_settings table
|
|
// and updates (or creates) the email sender. Called after admin settings change.
|
|
func (s *Server) reconfigureEmail() {
|
|
apiKey, _ := s.store.GetPlatformSetting(settingMailerooAPIKey)
|
|
fromAddr, _ := s.store.GetPlatformSetting(settingEmailFrom)
|
|
fromName, _ := s.store.GetPlatformSetting(settingEmailFromName)
|
|
|
|
if apiKey == "" {
|
|
return // No API key — leave email as-is (may still have env var config)
|
|
}
|
|
|
|
if s.email == nil {
|
|
// Create a new sender from platform settings
|
|
s.email = email.NewSender(apiKey, fromAddr, fromName, s.baseURL)
|
|
} else {
|
|
// Update existing sender
|
|
s.email.Configure(apiKey, fromAddr, fromName, s.baseURL)
|
|
}
|
|
}
|
|
|
|
// InitEmailFromSettings loads email config from platform settings on startup,
|
|
// merging with any env-var-based sender that was already attached.
|
|
func (s *Server) InitEmailFromSettings() {
|
|
s.reconfigureEmail()
|
|
}
|
|
|
|
func (s *Server) setupRouter() {
|
|
r := chi.NewRouter()
|
|
|
|
// Infrastructure middleware (applies to all routes including /metrics)
|
|
r.Use(chimiddleware.RealIP)
|
|
r.Use(chimiddleware.RequestID)
|
|
r.Use(StructuredLogger)
|
|
if s.metrics != nil {
|
|
r.Use(MetricsMiddleware(s.metrics))
|
|
}
|
|
r.Use(chimiddleware.Recoverer)
|
|
|
|
// Security headers (applies to all routes)
|
|
r.Use(SecurityHeaders)
|
|
if s.secureCookies {
|
|
r.Use(StrictTransportSecurity)
|
|
}
|
|
|
|
// Prometheus scrape endpoint — no auth/CSRF
|
|
if s.metrics != nil {
|
|
r.Group(func(r chi.Router) {
|
|
r.Handle("/metrics", promhttp.HandlerFor(s.metrics.Registry, promhttp.HandlerOpts{}))
|
|
})
|
|
}
|
|
|
|
// All other routes — full middleware stack
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: parseCORSOrigins(s.corsOrigins),
|
|
AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
r.Use(s.TokenAuth)
|
|
r.Use(s.SessionAuth)
|
|
r.Use(s.RateLimit)
|
|
r.Use(s.CSRFProtect)
|
|
r.Use(s.RequireAuth)
|
|
r.Use(jsonContentType)
|
|
|
|
// SSE endpoint (outside jsonContentType middleware — but inherits auth)
|
|
r.Get("/api/v1/events", s.handleSSE)
|
|
|
|
// API routes
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
r.Get("/health", s.handleHealth)
|
|
r.Get("/health/live", s.handleHealthLive)
|
|
r.Get("/health/ready", s.handleHealthReady)
|
|
|
|
// Auth endpoints (exempt from auth middleware)
|
|
r.Route("/auth", func(r chi.Router) {
|
|
r.Get("/session", s.handleSessionCheck)
|
|
r.Post("/bootstrap", s.handleBootstrap)
|
|
r.Post("/register", s.handleRegister)
|
|
r.Post("/login", s.handleLogin)
|
|
r.Post("/logout", s.handleLogout)
|
|
r.Get("/me", s.handleGetCurrentUser)
|
|
r.Patch("/me", s.handleUpdateCurrentUser)
|
|
|
|
// Password reset
|
|
r.Post("/forgot-password", s.handleForgotPassword)
|
|
r.Post("/reset-password", s.handleResetPassword)
|
|
|
|
// Two-factor authentication
|
|
r.Post("/2fa/setup", s.handleTOTPSetup)
|
|
r.Post("/2fa/verify", s.handleTOTPVerify)
|
|
r.Post("/2fa/disable", s.handleTOTPDisable)
|
|
r.Post("/2fa/login-verify", s.handleTOTPLoginVerify)
|
|
|
|
// User-scoped API tokens
|
|
r.Get("/tokens", s.handleListUserTokens)
|
|
r.Post("/tokens", s.handleCreateUserToken)
|
|
r.Delete("/tokens/{tokenID}", s.handleDeleteUserToken)
|
|
r.Post("/tokens/{tokenID}/rotate", s.handleRotateUserToken)
|
|
})
|
|
|
|
// Admin endpoints (admin-only, handlers check role internally)
|
|
r.Route("/admin", func(r chi.Router) {
|
|
r.Get("/settings", s.handleGetPlatformSettings)
|
|
r.Patch("/settings", s.handleUpdatePlatformSettings)
|
|
r.Post("/test-email", s.handleTestEmail)
|
|
})
|
|
|
|
// Audit log (admin-only)
|
|
r.Get("/audit-log", s.handleAuditLog)
|
|
|
|
// Templates
|
|
r.Get("/templates", s.handleListTemplates)
|
|
|
|
// Convention Library
|
|
r.Get("/convention-library", s.handleConventionLibrary)
|
|
|
|
// Playbook Library
|
|
r.Get("/playbook-library", s.handlePlaybookLibrary)
|
|
|
|
// Invitations (outside workspace scope)
|
|
r.Post("/invitations/{code}/accept", s.handleAcceptInvitation)
|
|
|
|
// Workspaces
|
|
r.Route("/workspaces", func(r chi.Router) {
|
|
r.Get("/", s.handleListWorkspaces)
|
|
r.Post("/", s.handleCreateWorkspace)
|
|
r.Post("/import", s.handleImportWorkspace)
|
|
r.Put("/reorder", s.handleReorderWorkspaces)
|
|
|
|
r.Route("/{slug}", func(r chi.Router) {
|
|
r.Use(s.RequireWorkspaceAccess)
|
|
|
|
r.Get("/", s.handleGetWorkspace)
|
|
r.Patch("/", s.handleUpdateWorkspace)
|
|
r.Delete("/", s.handleDeleteWorkspace)
|
|
r.Get("/export", s.handleExportWorkspace)
|
|
|
|
// Activity (workspace level)
|
|
r.Get("/activity", s.handleListWorkspaceActivity)
|
|
|
|
// Documents (v1 — will be replaced by items in Phase 2)
|
|
r.Route("/documents", func(r chi.Router) {
|
|
r.Get("/", s.handleListDocuments)
|
|
r.Post("/", s.handleCreateDocument)
|
|
|
|
r.Route("/{docID}", func(r chi.Router) {
|
|
r.Get("/", s.handleGetDocument)
|
|
r.Patch("/", s.handleUpdateDocument)
|
|
r.Delete("/", s.handleDeleteDocument)
|
|
r.Post("/restore", s.handleRestoreDocument)
|
|
|
|
// Versions
|
|
r.Get("/versions", s.handleListVersions)
|
|
r.Get("/versions/{versionID}", s.handleGetVersion)
|
|
|
|
// Activity (document level)
|
|
r.Get("/activity", s.handleListDocumentActivity)
|
|
})
|
|
})
|
|
|
|
// Collections (v2)
|
|
r.Route("/collections", func(r chi.Router) {
|
|
r.Get("/", s.handleListCollections)
|
|
r.Post("/", s.handleCreateCollection)
|
|
r.Route("/{collSlug}", func(r chi.Router) {
|
|
r.Get("/", s.handleGetCollection)
|
|
r.Patch("/", s.handleUpdateCollection)
|
|
r.Delete("/", s.handleDeleteCollection)
|
|
// Items within collection
|
|
r.Get("/items", s.handleListCollectionItems)
|
|
r.Post("/items", s.handleCreateItem)
|
|
// Saved views within collection
|
|
r.Get("/views", s.handleListViews)
|
|
r.Post("/views", s.handleCreateView)
|
|
r.Route("/views/{viewID}", func(r chi.Router) {
|
|
r.Patch("/", s.handleUpdateView)
|
|
r.Delete("/", s.handleDeleteView)
|
|
})
|
|
})
|
|
})
|
|
|
|
// Plans progress
|
|
r.Get("/plans-progress", s.handlePlansProgress)
|
|
|
|
// Items (cross-collection, v2)
|
|
r.Get("/items", s.handleListItems)
|
|
r.Route("/items/{itemSlug}", func(r chi.Router) {
|
|
r.Get("/", s.handleGetItem)
|
|
r.Patch("/", s.handleUpdateItem)
|
|
r.Delete("/", s.handleDeleteItem)
|
|
r.Post("/restore", s.handleRestoreItem)
|
|
r.Post("/move", s.handleMoveItem)
|
|
r.Get("/versions", s.handleListItemVersions)
|
|
r.Post("/versions/{versionID}/restore", s.handleRestoreItemVersion)
|
|
r.Get("/activity", s.handleListItemActivity)
|
|
r.Get("/links", s.handleGetItemLinks)
|
|
r.Post("/links", s.handleCreateItemLink)
|
|
r.Get("/comments", s.handleListComments)
|
|
r.Post("/comments", s.handleCreateComment)
|
|
r.Get("/timeline", s.handleListItemTimeline)
|
|
r.Get("/children", s.handleGetItemChildren)
|
|
r.Get("/progress", s.handleGetItemProgress)
|
|
r.Get("/tasks", s.handleGetItemChildren) // deprecated alias
|
|
})
|
|
|
|
// Links (v2)
|
|
r.Delete("/links/{linkID}", s.handleDeleteItemLink)
|
|
|
|
// Comments (v2)
|
|
r.Route("/comments/{commentID}", func(r chi.Router) {
|
|
r.Delete("/", s.handleDeleteComment)
|
|
r.Post("/replies", s.handleCreateReply)
|
|
r.Post("/reactions", s.handleAddReaction)
|
|
r.Delete("/reactions/{emoji}", s.handleRemoveReaction)
|
|
})
|
|
|
|
// Role Board (cross-collection role-based view)
|
|
r.Get("/roles/board", s.handleRoleBoard)
|
|
r.Put("/roles/board/reorder", s.handleRoleBoardReorder)
|
|
r.Put("/roles/board/lane-order", s.handleRoleBoardLaneReorder)
|
|
|
|
// Agent Roles
|
|
r.Route("/agent-roles", func(r chi.Router) {
|
|
r.Get("/", s.handleListAgentRoles)
|
|
r.Post("/", s.handleCreateAgentRole)
|
|
r.Route("/{roleID}", func(r chi.Router) {
|
|
r.Get("/", s.handleGetAgentRole)
|
|
r.Patch("/", s.handleUpdateAgentRole)
|
|
r.Delete("/", s.handleDeleteAgentRole)
|
|
})
|
|
})
|
|
|
|
// Webhooks
|
|
r.Route("/webhooks", func(r chi.Router) {
|
|
r.Get("/", s.handleListWebhooks)
|
|
r.Post("/", s.handleCreateWebhook)
|
|
r.Route("/{webhookID}", func(r chi.Router) {
|
|
r.Delete("/", s.handleDeleteWebhook)
|
|
r.Post("/test", s.handleTestWebhook)
|
|
})
|
|
})
|
|
|
|
// API Tokens
|
|
r.Route("/tokens", func(r chi.Router) {
|
|
r.Get("/", s.handleListTokens)
|
|
r.Post("/", s.handleCreateToken)
|
|
r.Delete("/{tokenID}", s.handleDeleteToken)
|
|
})
|
|
|
|
// Members
|
|
r.Route("/members", func(r chi.Router) {
|
|
r.Get("/", s.handleListMembers)
|
|
r.Post("/invite", s.handleInviteMember)
|
|
r.Delete("/invitations/{invID}", s.handleCancelInvitation)
|
|
r.Delete("/{userID}", s.handleRemoveMember)
|
|
r.Patch("/{userID}", s.handleUpdateMemberRole)
|
|
})
|
|
|
|
// Dashboard (v2)
|
|
r.Get("/dashboard", s.handleGetDashboard)
|
|
|
|
// Incremental sync — returns items changed since a timestamp
|
|
r.Get("/changes", s.handleGetChanges)
|
|
})
|
|
})
|
|
|
|
// Search
|
|
r.Get("/search", s.handleSearch)
|
|
})
|
|
}) // end r.Group (full middleware stack)
|
|
|
|
s.router = r
|
|
}
|
|
|
|
// SetWebUI sets the embedded web UI filesystem for serving the SPA.
|
|
func (s *Server) SetWebUI(fsys fs.FS) {
|
|
s.webFS = fsys
|
|
s.ensureRouter()
|
|
s.router.Handle("/*", s.spaHandler())
|
|
}
|
|
|
|
func (s *Server) spaHandler() http.Handler {
|
|
fileServer := http.FileServer(http.FS(s.webFS))
|
|
indexHTML, _ := fs.ReadFile(s.webFS, "index.html")
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
if strings.HasPrefix(path, "/api/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
cleanPath := strings.TrimPrefix(path, "/")
|
|
if cleanPath != "" {
|
|
if _, err := fs.Stat(s.webFS, cleanPath); err == nil {
|
|
if strings.Contains(path, "/immutable/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Generate per-request nonce for inline script CSP
|
|
nonce := generateCSPNonce()
|
|
|
|
// Inject nonce into inline <script> tags (SvelteKit bootstrap)
|
|
html := bytes.Replace(indexHTML, []byte("<script>"), []byte(fmt.Sprintf(`<script nonce="%s">`, nonce)), -1)
|
|
|
|
// Set nonce-based CSP (overrides the strict default from SecurityHeaders)
|
|
w.Header().Set("Content-Security-Policy", fmt.Sprintf(
|
|
"default-src 'self'; script-src 'self' 'nonce-%s'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'",
|
|
nonce))
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(html)
|
|
})
|
|
}
|
|
|
|
// ensureRouter lazily initializes the router on first use, so all Set*
|
|
// configuration is applied before the middleware chain is built.
|
|
func (s *Server) ensureRouter() {
|
|
s.routerOnce.Do(func() {
|
|
s.setupRouter()
|
|
})
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.ensureRouter()
|
|
s.router.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) ListenAndServe(addr string) error {
|
|
s.ensureRouter()
|
|
|
|
s.httpServer = &http.Server{
|
|
Addr: addr,
|
|
Handler: s.router,
|
|
ReadTimeout: 15 * time.Second,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
// WriteTimeout left at 0 — SSE connections are long-lived.
|
|
// Non-SSE handlers should use per-request context deadlines.
|
|
}
|
|
|
|
slog.Info("Pad server listening", "addr", addr)
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
// Shutdown gracefully drains in-flight requests and stops the HTTP server.
|
|
// The provided context controls how long to wait for active connections.
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
if s.httpServer == nil {
|
|
return nil
|
|
}
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
// Handler returns the configured HTTP handler (router).
|
|
// Useful for testing with httptest.NewServer.
|
|
func (s *Server) Handler() http.Handler {
|
|
s.ensureRouter()
|
|
return s.router
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func jsonContentType(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if len(r.URL.Path) >= 7 && r.URL.Path[:7] == "/api/v1" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
slog.Error("failed to encode JSON response", "error", err)
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"error": map[string]string{
|
|
"code": code,
|
|
"message": message,
|
|
},
|
|
})
|
|
}
|
|
|
|
// writeInternalError logs the real error server-side and sends a generic
|
|
// message to the client. This prevents leaking SQL errors, file paths,
|
|
// and other internal details.
|
|
func writeInternalError(w http.ResponseWriter, err error) {
|
|
slog.Error("internal server error", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "An internal error occurred")
|
|
}
|
|
|
|
func decodeJSON(r *http.Request, v interface{}) error {
|
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
|
return fmt.Errorf("invalid JSON: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getWorkspaceID resolves workspace slug to ID.
|
|
func (s *Server) getWorkspaceID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
slug := chi.URLParam(r, "slug")
|
|
ws, err := s.store.GetWorkspaceBySlug(slug)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return "", false
|
|
}
|
|
if ws == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
|
|
return "", false
|
|
}
|
|
return ws.ID, true
|
|
}
|
|
|
|
// getWorkspaceDocument resolves workspace slug and document ID from URL params.
|
|
func (s *Server) getWorkspaceDocument(w http.ResponseWriter, r *http.Request) (string, *models.Document, bool) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return "", nil, false
|
|
}
|
|
|
|
docID := chi.URLParam(r, "docID")
|
|
doc, err := s.store.GetDocument(docID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return "", nil, false
|
|
}
|
|
if doc == nil || doc.WorkspaceID != workspaceID {
|
|
writeError(w, http.StatusNotFound, "not_found", "Document not found")
|
|
return "", nil, false
|
|
}
|
|
return workspaceID, doc, true
|
|
}
|