package server import ( "bytes" "context" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "io/fs" "log/slog" "net" "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/PerpetualSoftware/pad/internal/attachments" "github.com/PerpetualSoftware/pad/internal/billing" "github.com/PerpetualSoftware/pad/internal/email" "github.com/PerpetualSoftware/pad/internal/events" "github.com/PerpetualSoftware/pad/internal/metrics" "github.com/PerpetualSoftware/pad/internal/models" "github.com/PerpetualSoftware/pad/internal/store" "github.com/PerpetualSoftware/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) emailAPIKey string // Maileroo API key (used for unsubscribe HMAC) 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) metricsToken string // shared bearer token for /metrics scrapes ("" = loopback-only) trustedProxyCIDRs []*net.IPNet // CIDRs allowed to set X-Forwarded-For (nil = proxy headers untrusted) ipChangeEnforceStrict bool // when true, reject sessions whose client IP differs from the one recorded at session creation sseMaxConnections int // global SSE connection limit (0 = unlimited) sseMaxPerWorkspace int // per-workspace SSE connection limit (0 = unlimited) cloudMode bool // true when running as Pad Cloud (PAD_CLOUD=true or PAD_MODE=cloud) cloudSecrets []string // shared secrets for sidecar ↔ pad communication (supports rotation) cloudSidecar CloudSidecar // reverse pad → pad-cloud client (e.g. Stripe cancel on account delete); nil = not configured 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 // Attachments storage. Wired via SetAttachments at startup; nil-checked // by handlers so a server constructed for a test that doesn't need // uploads still compiles and serves every other endpoint. attachments *attachments.Registry attachmentMaxBytes int64 // per-file upload cap; 0 = use defaultAttachmentMaxBytes // Image processor used by the upload handler to derive thumbnail // variants (TASK-878) and by the editor's rotate / crop tools // (TASK-879/880). Wired via SetImageProcessor; nil-checked by // callers so a server without image processing — e.g. a self-host // build that doesn't want the dependency — still serves every // other endpoint and stores originals untouched. imageProcessor attachments.Processor // storageInfoCache memoizes per-workspace storage usage summaries // behind a short TTL (storageInfoTTL). Reduces DB load on the // Settings → Storage page and quota-aware UI surfaces. Initialized // in newServer; never nil so handlers can call get/set without // guarding. storageInfoCache *storageInfoCache // bg tracks fire-and-forget goroutines spawned by request handlers // (TouchUserActivity in middleware_auth, async email sends, etc.) so // the server can drain them before shutdown / test cleanup. Without // this, tests using t.TempDir() race the still-running goroutine's // SQLite WAL write against TempDir RemoveAll, leaving "directory not // empty" cleanup errors in CI. See BUG-842. bg sync.WaitGroup } // goAsync spawns fn in a goroutine that's tracked by s.bg, so Stop() can // wait for in-flight background work to finish. Use this for any // fire-and-forget work that touches the database, filesystem, or external // services from inside a request handler — never bare `go func() {...}()`. func (s *Server) goAsync(fn func()) { s.bg.Add(1) go func() { defer s.bg.Done() fn() }() } // Stop waits for all background goroutines started via goAsync to finish // AND drains the rate-limiter cleanup goroutines spawned at construction // time (BUG-851). Safe to call multiple times. Should be called before // Store.Close() so in-flight DB writes don't race a closed connection // (or worse, the SQLite -wal/-shm file removal in t.TempDir cleanup). func (s *Server) Stop() { s.bg.Wait() s.rateLimiters.Stop() // nil-safe via the RateLimiters receiver guard } func New(s *store.Store) *Server { return &Server{ store: s, rateLimiters: NewRateLimiters(), storageInfoCache: newStorageInfoCache(storageInfoTTL), } } // 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 } // SetCloudMode enables cloud mode with the shared sidecar secret(s). // Accepts a comma-separated list of secrets for rotation support: // "new-key,old-key" — both are accepted for INBOUND calls from pad-cloud. // The OUTBOUND direction (pad → pad-cloud, see SetCloudSidecar) is // configured separately via PAD_CLOUD_OUTBOUND_SECRET or derived from the // last entry of this list — see cmd/pad/main.go for the resolution order. func (s *Server) SetCloudMode(secret string) { s.cloudMode = true for _, k := range strings.Split(secret, ",") { k = strings.TrimSpace(k) if k != "" { s.cloudSecrets = append(s.cloudSecrets, k) } } } // CloudSidecar is the reverse pad → pad-cloud client interface. Concrete // implementation lives in internal/billing so server has no direct Stripe // dependency. Kept as an interface so tests can inject fakes without // spinning up a real HTTP server or touching Stripe. type CloudSidecar interface { // CancelCustomer asks pad-cloud to cancel every active Stripe subscription // for customerID and then delete the Stripe customer object. Used by // handleDeleteAccount to cascade account deletion through to Stripe billing // (TASK-690). // // Failure contract: any non-nil error means the caller MUST abort the // local delete. pad-cloud normalizes Stripe's "already gone" cases to a // 200 on its side (see pad-cloud stripe.go isStripeAlreadyGone), so // every error we see here is a real failure — transport, 4xx (ops // misconfig), or 5xx (upstream breakage). Continuing after an error // would wipe the user's StripeCustomerID while leaving the subscription // billing, which is exactly the regression TASK-690 exists to prevent. CancelCustomer(customerID string) error // GetBillingMetrics fetches an aggregated Stripe-derived snapshot from // pad-cloud's /admin/metrics/billing endpoint (active subs, MRR, ARR, // churn, cancellations). Used by handleAdminBillingStats to power the // admin Billing dashboard (TASK-827 / PLAN-825). // // Failure contract: returns an error on transport failure or non-200 // status. The admin handler treats any error as "degrade to local-only" // and surfaces the distinction in its response via cloud_unreachable — // it never propagates the upstream failure to the operator's browser. GetBillingMetrics() (*billing.BillingMetricsResponse, error) } // SetCloudSidecar installs the reverse pad → pad-cloud client. Called from // cmd/pad/main.go when PAD_CLOUD_SIDECAR_URL + PAD_CLOUD_SECRET are set. // When unset, handleDeleteAccount skips the Stripe cancel step (self-hosted // deploys that don't run a Stripe-backed sidecar have nothing to cascade). func (s *Server) SetCloudSidecar(c CloudSidecar) { s.cloudSidecar = c } // IsCloud reports whether the server is running in cloud mode. func (s *Server) IsCloud() bool { return s.cloudMode } // 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. // The apiKey is stored separately for deriving the unsubscribe HMAC secret. func (s *Server) SetEmailSender(e *email.Sender, apiKey ...string) { s.email = e if len(apiKey) > 0 { s.emailAPIKey = apiKey[0] } } // SetCORSOrigins configures allowed CORS origins (comma-separated). func (s *Server) SetCORSOrigins(origins string) { s.corsOrigins = origins } // SetAttachments wires the attachment storage Registry that the upload // and download handlers use. Pass maxBytes = 0 to keep the // defaultAttachmentMaxBytes ceiling (25 MiB). func (s *Server) SetAttachments(reg *attachments.Registry, maxBytes int64) { s.attachments = reg s.attachmentMaxBytes = maxBytes } // SetImageProcessor wires the image processor that the upload handler // uses to derive thumbnail variants (TASK-878). Optional — without it // uploads still succeed but no thumbnails are generated; the // download handler's variant fallback path returns the original blob. // The capabilities endpoint reflects whichever processor is wired. func (s *Server) SetImageProcessor(p attachments.Processor) { s.imageProcessor = p } // 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 } // SetMetricsToken configures the static bearer token required to scrape // /metrics. When empty (the default), /metrics is exposed only to loopback // callers so a self-hosted Prometheus on the same host keeps working // without config — but LAN/internet scrapes are refused. A non-empty // token requires "Authorization: Bearer " regardless of source. func (s *Server) SetMetricsToken(token string) { s.metricsToken = strings.TrimSpace(token) } // metricsAuth gates the /metrics endpoint. See SetMetricsToken for the // policy. Uses constant-time comparison to avoid leaking the configured // token via response timing. func (s *Server) metricsAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.metricsToken == "" { // No token configured → loopback-only access. if !requestIsLoopback(r) { writeError(w, http.StatusForbidden, "forbidden", "/metrics is restricted to loopback when PAD_METRICS_TOKEN is unset") return } next.ServeHTTP(w, r) return } const prefix = "Bearer " authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, prefix) { w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`) writeError(w, http.StatusUnauthorized, "unauthorized", "Missing Bearer token for /metrics") return } given := strings.TrimSpace(strings.TrimPrefix(authHeader, prefix)) if subtle.ConstantTimeCompare([]byte(given), []byte(s.metricsToken)) != 1 { w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`) writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid Bearer token for /metrics") return } next.ServeHTTP(w, r) }) } // 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 } // SetTrustedProxies configures which direct TCP peers are allowed to set // X-Real-IP / X-Forwarded-For on incoming requests. Accepts a comma- // separated list of CIDRs or bare IPs (e.g. "10.0.0.0/8, 172.16.0.0/12"). // When empty (the default), proxy headers are ignored entirely — the // actual TCP peer address is used for rate limiting, the bootstrap // loopback check, and audit logging. func (s *Server) SetTrustedProxies(spec string) { s.trustedProxyCIDRs = ParseTrustedProxyCIDRs(spec) } // SetIPChangeEnforce controls how the auth middleware reacts when a // session's client IP changes mid-lifetime: // - mode == "strict": reject the request (session treated as possibly stolen) // - anything else (default): log to the audit log, update the stored IP, // and let the request through. Strict mode breaks legitimate mobility // (mobile roaming, VPN toggles) so it is opt-in for high-sensitivity // deployments via the PAD_IP_CHANGE_ENFORCE env var. func (s *Server) SetIPChangeEnforce(mode string) { s.ipChangeEnforceStrict = strings.EqualFold(strings.TrimSpace(mode), "strict") } // 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) } s.emailAPIKey = apiKey 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) // CapturePeerAddr MUST run before TrustedProxyRealIP so downstream code // that needs to verify the real TCP peer (e.g. the bootstrap loopback // check) can read the untampered value from request context even on // deployments with a trusted reverse proxy in front. r.Use(CapturePeerAddr) // RealIP is gated on PAD_TRUSTED_PROXIES. When unset (the default), proxy // headers are ignored and the real TCP peer address is used everywhere. // This prevents X-Forwarded-For spoofing from bypassing rate limits, the // bootstrap loopback check, or audit logs on direct-exposed deployments. r.Use(TrustedProxyRealIP(s.trustedProxyCIDRs)) 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 — exempt from the standard auth/CSRF stack // (Prometheus can't present a session cookie or pass a CSRF header), but // gated by a dedicated static bearer token. Without the gate, any // unauthenticated caller on the network can read workspace counts, API // usage patterns, and — via label enumeration — user/workspace IDs. // // The gate runs in three layers: // 1. No PAD_METRICS_TOKEN → endpoint is open ONLY to loopback. Safe // default for self-hosters running Prometheus on the same box. // 2. PAD_METRICS_TOKEN set → "Authorization: Bearer " required. // Compared in constant time; empty/missing header → 401. // 3. In either case the SecurityHeaders / rate-limit / logging chain // already wraps this group from the outer r.Use() calls above. if s.metrics != nil { r.Group(func(r chi.Router) { r.Use(s.metricsAuth) 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", "X-Share-Password"}, // Credentials flag is gated on an operator explicitly listing // PAD_CORS_ORIGINS. The CLI uses Bearer tokens so the default // "no CORS_ORIGINS set" path doesn't need credential sharing; // leaving it off by default prevents cross-origin fetches from // a browser on a different site from piggy-backing cookies // on the victim's session. AllowCredentials: corsAllowCredentials(s.corsOrigins), 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) r.Get("/plan-limits", s.handleGetPlanLimits) // Public: billing page reads plan limits r.Get("/unsubscribe", s.handleUnsubscribe) // Public: email opt-out (HMAC-signed) // Server capabilities — public so the editor can fetch it // pre-login and gate per-format rotate / crop UI on the // processor's reach (TASK-878). The response is static for // the lifetime of the binary; clients can cache freely. r.Get("/server/capabilities", s.handleServerCapabilities) // 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.Get("/check-username", s.handleCheckUsername) 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) // Account management (GDPR) r.Post("/delete-account", s.handleDeleteAccount) r.Get("/export", s.handleExportAccount) // 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) // Cloud: OAuth login/linking (called by pad-cloud sidecar, protected by cloud secret) r.Post("/oauth-login", s.handleOAuthLogin) r.Post("/oauth-link", s.handleOAuthLink) r.Post("/oauth-unlink", s.handleOAuthUnlink) // CLI browser-based auth flow r.Post("/cli/sessions", s.handleCreateCLIAuthSession) r.Get("/cli/sessions/{code}", s.handlePollCLIAuthSession) r.Post("/cli/sessions/{code}/approve", s.handleApproveCLIAuthSession) }) // 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) // Cloud sidecar endpoints — only exist in cloud mode. requireCloudMode // returns 404 outside cloud mode so a self-hosted deployment doesn't // expose "Cloud mode not configured" to unauthenticated probes. r.Group(func(r chi.Router) { r.Use(s.requireCloudMode) r.Post("/plan", s.handleSetPlan) // Cloud: sidecar sets user plans; also accessible to admins r.Post("/stripe-customer-id", s.handleSetStripeCustomerID) // Cloud: sidecar stores Stripe customer ID after checkout r.Get("/user-by-customer", s.handleGetUserByCustomerID) // Cloud: sidecar looks up user by Stripe customer ID r.Post("/stripe-event-processed", s.handleStripeEventProcessed) // Cloud: sidecar webhook idempotency (TASK-696) r.Post("/stripe-event-unmark", s.handleStripeEventUnmark) // Cloud: sidecar handler-failure rollback (TASK-736) r.Post("/payment-failed", s.handlePaymentFailed) // Cloud: sidecar forwards invoice.payment_failed to trigger email (TASK-712) // Admin Billing dashboard data (TASK-827 / PLAN-825). Proxies // pad-cloud's /admin/metrics/billing for Stripe-derived stats // (active subs, MRR, ARR, churn) and merges with local // users-table aggregates (customers_by_plan, new_signups_30d). // Always returns 200; degraded states (sidecar unreachable, // Stripe not configured) are surfaced as flags in the body. r.Get("/billing-stats", s.handleAdminBillingStats) }) // User management r.Get("/users", s.handleAdminListUsers) r.Get("/users/{userID}", s.handleAdminGetUser) r.Patch("/users/{userID}", s.handleAdminUpdateUser) r.Post("/users/{userID}/reset-password", s.handleAdminResetPassword) r.Get("/users/{userID}/workspaces", s.handleAdminGetUserWorkspaces) r.Post("/users/{userID}/disable", s.handleAdminDisableUser) r.Post("/users/{userID}/enable", s.handleAdminEnableUser) // Invitations r.Get("/invitations", s.handleAdminListInvitations) r.Post("/invitations/{invID}/resend", s.handleAdminResendInvitation) r.Delete("/invitations/{invID}", s.handleAdminDeleteInvitation) // Plan limits r.Get("/limits", s.handleAdminGetLimits) r.Patch("/limits", s.handleAdminUpdateLimits) // Platform stats r.Get("/stats", s.handleAdminStats) }) // 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) // Share link resolution (outside workspace scope, no auth required) r.Get("/s/{token}", s.handleResolveShareLink) // 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) // Collection grants r.Get("/grants", s.handleListCollectionGrants) r.Post("/grants", s.handleCreateCollectionGrant) r.Delete("/grants/{grantID}", s.handleDeleteCollectionGrant) r.Get("/share-links", s.handleListCollectionShareLinks) r.Post("/share-links", s.handleCreateCollectionShareLink) // 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) // User grants (all grants for a specific user in this workspace) r.Get("/users/{userID}/grants", s.handleListUserGrants) // Starred items r.Get("/starred", s.handleListStarredItems) // 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 r.Get("/grants", s.handleListItemGrants) r.Post("/grants", s.handleCreateItemGrant) r.Delete("/grants/{grantID}", s.handleDeleteItemGrant) r.Get("/share-links", s.handleListItemShareLinks) r.Post("/share-links", s.handleCreateItemShareLink) // Stars r.Get("/star", s.handleGetItemStarStatus) r.Post("/star", s.handleStarItem) r.Delete("/star", s.handleUnstarItem) }) // Links (v2) r.Delete("/links/{linkID}", s.handleDeleteItemLink) // Share links (workspace-scoped management) r.Delete("/share-links/{linkID}", s.handleDeleteShareLink) r.Get("/share-links/{linkID}/views", s.handleShareLinkViews) // 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) }) }) // Attachments // POST /attachments — upload (TASK-871) // GET /attachments/{attachmentID} — serve blob (TASK-872, supports ?variant=) // HEAD /attachments/{attachmentID} — metadata only (TASK-877 file-chip enrichment) // POST /attachments/{attachmentID}/transform — server-side rotate/crop (TASK-879/880) // // chi does not auto-route HEAD to the GET handler, so the // editor's HEAD probe for size + MIME has to be registered // explicitly. The handler short-circuits the streaming // path on HEAD; http.ServeContent already strips the body // on the seekable path. r.Post("/attachments", s.handleUploadAttachment) r.Get("/attachments", s.handleListWorkspaceAttachments) r.Get("/attachments/{attachmentID}", s.handleGetAttachment) r.Head("/attachments/{attachmentID}", s.handleGetAttachment) r.Post("/attachments/{attachmentID}/transform", s.handleTransformAttachment) r.Delete("/attachments/{attachmentID}", s.handleDeleteWorkspaceAttachment) // Storage usage summary for Settings → Storage and other // quota-aware UI surfaces (TASK-881). Cached behind a // short TTL — see handleGetWorkspaceStorageUsage. r.Get("/storage/usage", s.handleGetWorkspaceStorageUsage) // 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) r.Get("/{userID}/collection-access", s.handleGetMemberCollectionAccess) r.Put("/{userID}/collection-access", s.handleSetMemberCollectionAccess) }) // 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, err := fs.ReadFile(s.webFS, "index.html") if err != nil { // Embedded web UI is missing — fail fast instead of silently // serving blank HTML to every request. This indicates a broken // build, so the server should refuse to start. panic(fmt.Sprintf("spaHandler: failed to read embedded index.html: %v", err)) } 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