mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 17:43:26 +00:00
504d348917
* feat(attachments): Settings → Storage tab with attachment list (TASK-882)
Adds the Settings → Storage tab and the underlying list/delete API
endpoints so workspace owners can audit and reclaim attachment bytes.
Backend (TASK-882 needs this — there was no list/delete API yet):
- store.WorkspaceAttachments: paginated list with filter (category,
attached/unattached, collection_id) + sort allowlist (size, filename,
created_at — each with desc variant). LEFT JOIN to items + collections
enriches each row with item_title/slug + collection_slug for the
"in [[Item]]" link. Hides derived (thumbnail) rows by default — they
count toward quota but are managed automatically and would clutter
the user-facing list.
- store.SoftDeleteAttachment: tombstones the row + every variant. Blob
on disk stays put; orphan GC reclaims past the grace period (TASK-886).
- GET /workspaces/{ws}/attachments — viewer+, returns
{attachments, total, limit, offset}.
- DELETE /workspaces/{ws}/attachments/{id} — editor+. Refuses to delete
derived rows directly (returns 400 with derived_attachment code) and
invalidates the storage-usage cache.
Frontend:
- StorageTab.svelte component (lib/components/settings) with usage bar
(color thresholds at 80%/100%, override badge), 5-select filter row
(category, item, collection, sort, page size), attachment list with
thumbnails (image variants via thumb-sm, emoji icon otherwise), item
link, MIME, size, date, and per-row delete with confirm() dialog.
Pagination footer with Prev/Next + "showing X–Y of Z".
- TS api.attachments.list() / delete() + types.
- Wired as a new "Storage" tab on the workspace settings page.
Tests:
- TestListAttachments_Pagination: 3 uploads, default + size-asc sort,
limit/offset paging.
- TestListAttachments_HidesDerived: synthetic thumbnail row, asserts
the list excludes parent_id != NULL rows.
- TestDeleteAttachment_HappyPath: upload → delete → list empty →
storage usage drops to 0 (cache invalidation hook fires) → second
delete returns 404.
- TestDeleteAttachment_DerivedRefused: thumbnail rows can't be deleted
directly via the API.
Parent: PLAN-866.
* fix(attachments): collection visibility + category gaps + item ref shape per Codex (round 1)
Three findings from Codex on PR #303 round 1:
P1 — Collection visibility leak. The storage list returned all
workspace attachments without applying per-user collection access,
so a member with collection_access=specific would receive hidden
collections' attachment IDs/filenames/item titles and could then
pull the bytes via the existing download endpoint.
Fixed by threading visibleCollectionIDs(r, workspaceID) through to
the store filter. nil = admin/no restriction; empty slice = zero
visible collections (zero rows by design); explicit set = restrict
i.collection_id IN (...). Orphans (item_id IS NULL) are excluded
for restricted users since their filenames would still leak.
P2 — item_ref shape didn't match the route. The store synthesized
"<collection_slug>/<item_number>" and the UI inserted it verbatim
into the URL, producing /user/ws/tasks/tasks/5. Dropped item_ref
entirely; UI now builds URLs from item_slug + collection_slug
which is the actual route shape.
P2 — Category filter coverage. mimePrefixForCategory only handled
image/video/audio. Selecting Documents/Text/Archive/Other in the
UI silently passed through with no MIME predicate so the list
showed everything. Replaced with mimePredicateForCategory which
emits the right SQL fragment per bucket: prefix LIKE for the type/
buckets, explicit IN list for document/text/archive (mirroring the
allowlist in internal/attachments/mime.go), and a NOT-IN composite
for "other".
Tests:
- TestWorkspaceAttachments_VisibilityFilter: admin sees all 3 rows;
restricted to one collection sees only that collection's row +
orphan suppressed; empty visibility yields zero rows.
- TestWorkspaceAttachments_CategoryFilters: image/document/text/
archive/other each return exactly the matching MIME types.
* fix(attachments): item-level visibility on list + delete per Codex (round 2)
Two more findings from Codex on PR #303 round 2:
1. The list filter used VisibleCollectionIDs alone — but that set
includes collections containing any item-level grant for the user.
A guest with one item granted in collection B would still receive
attachment metadata for every item in collection B. Replaced with
the (fullCollIDs, grantedItemIDs) tuple from guestResourceFilter so
the SQL ORs collection-level full access against per-item grants,
matching how handlers_search / handlers_activity narrow lists.
2. The delete endpoint validated workspace membership but never
checked the attachment's parent item is visible to the caller.
An editor with restricted collection access could delete
attachments in hidden collections by guessing/obtaining the
attachment ID. Added requireItemVisible after fetching the parent
item, plus a fallback gate for orphan attachments (item_id IS
NULL) so restricted users get 404 there as well.
Store-level filter renamed: VisibleCollectionIDs → Restricted +
FullCollectionIDs + GrantedItemIDs. Tests cover the collection-only,
item-grant-only, and zero-visibility paths.
* fix(attachments): allow deleting attachments when parent item is soft-deleted (round 3)
Codex P2 from PR #303 round 3: the storage list intentionally surfaces
attachments whose parent item has been soft-deleted (so the user sees
what's still consuming quota), but the delete handler used GetItem,
which filters soft-deleted out and returned 404 before
SoftDeleteAttachment could run — turning every Delete button on those
rows into a no-op.
Fixed by adding store.GetItemIncludeDeleted (mirroring the existing
GetItemBySlugIncludeDeleted) and switching the delete path to use it.
The visibility check still keys off the (still-set) collection_id, so
soft-deleting an item doesn't escalate access — restricted users still
hit requireItemVisible's 404 if they couldn't see the parent.
Regression test: create item → attach → soft-delete item → list still
returns the row → delete returns 204.
* fix(attachments): list surfaces attachments under soft-deleted parents (round 4)
Codex round-4 finding: WorkspaceAttachments still LEFT JOIN'd items
with AND i.deleted_at IS NULL, so attachments whose parent item was
soft-deleted disappeared from the list — even though the previous
round wired GetItemIncludeDeleted on the delete path. Net effect:
restricted editors with access to that collection couldn't discover
the row in the UI; only full-access users saw it as an orphan-looking
entry.
Fix: drop the deleted_at filter from the JOIN. The collection ACL
predicate (i.collection_id IN ...) now sees the (still-set)
collection_id from the soft-deleted item, so visibility behaves
consistently for live and tombstoned parents. Soft-deleted items
don't escalate access — the collection_id stays put.
UX: response now carries item_deleted=true when the parent is
soft-deleted; the StorageTab renders the title with strike-through
+ a small "deleted" badge instead of a clickable link (which would
404).
Tests:
- store-level: admin/full-access sees the row + ItemDeleted flag,
restricted-to-correct-collection sees it, restricted-to-other-
collection does not.
- (existing TestDeleteAttachment_AfterParentSoftDeleted continues
to pass on the handler side.)
1305 lines
50 KiB
Go
1305 lines
50 KiB
Go
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 <token>" 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 <token>" 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 <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).
|
|
// - 'nonce-<N>' authorizes the SvelteKit bootstrap <script> we inject below.
|
|
// - 'strict-dynamic' lets that trusted script dynamically import() the
|
|
// SvelteKit runtime chunks without listing every build-hashed path. In
|
|
// browsers that honor CSP L3, 'strict-dynamic' supersedes the 'self'
|
|
// host-list, so an XSS gap that injects <script src="//evil.com"> is
|
|
// rejected even though 'self' is present. 'self' stays as a fallback
|
|
// for older browsers that don't implement strict-dynamic.
|
|
// - script-src-attr 'none' blocks inline event handlers regardless of the
|
|
// script-src nonce — per CSP spec, event attributes bypass script-src.
|
|
w.Header().Set("Content-Security-Policy", fmt.Sprintf(
|
|
"default-src 'self'; script-src 'self' 'nonce-%s' 'strict-dynamic'; script-src-attr 'none'; 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,
|
|
// Cap total header bytes (default 1 MB) to 64 KB — well above any
|
|
// legitimate request (cookies, auth, content-type, a few CSRF/CORS
|
|
// headers) and tight enough to cheaply reject header-flood DoS.
|
|
MaxHeaderBytes: 64 * 1024,
|
|
// 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")
|
|
}
|
|
|
|
// defaultJSONBodyLimit is the default cap applied to JSON request bodies
|
|
// by decodeJSON. Every /api/* POST/PATCH is comfortably small in practice
|
|
// (items, collections, auth payloads — all well under 100 KB), so the
|
|
// 2 MB cap is several orders of magnitude above real traffic while still
|
|
// cheap to hold in memory per request. Callers who legitimately need
|
|
// more — bulk imports — should call decodeJSONWithLimit explicitly.
|
|
const defaultJSONBodyLimit = 2 << 20 // 2 MiB
|
|
|
|
// decodeJSON reads and unmarshals the JSON body into v. Wraps the body in
|
|
// http.MaxBytesReader so an attacker can't exhaust memory by POSTing a
|
|
// multi-GB JSON blob — without this, json.NewDecoder.Decode happily
|
|
// streams the whole body into a single allocation.
|
|
func decodeJSON(r *http.Request, v interface{}) error {
|
|
return decodeJSONWithLimit(r, v, defaultJSONBodyLimit)
|
|
}
|
|
|
|
// decodeJSONWithLimit is the size-configurable variant. Use this for
|
|
// endpoints that accept large payloads (e.g. bulk-import) where the
|
|
// default cap is too small — but always pass an explicit cap, never
|
|
// remove the wrapper.
|
|
func decodeJSONWithLimit(r *http.Request, v interface{}, maxBytes int64) error {
|
|
// http.MaxBytesReader.Close() is a no-op; the decoder leaves r.Body at
|
|
// EOF anyway. Setting this here also lets the server return a 413
|
|
// automatically via the error we wrap below.
|
|
if r.Body != nil {
|
|
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
|
return fmt.Errorf("invalid JSON: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getWorkspaceID resolves workspace slug/ID from the request.
|
|
// If RequireWorkspaceAccess already resolved the workspace, reads from context.
|
|
// Otherwise falls back to direct resolution (for unauthenticated paths).
|
|
func (s *Server) getWorkspaceID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
// Fast path: already resolved by RequireWorkspaceAccess middleware
|
|
if wsID, ok := r.Context().Value(ctxResolvedWorkspaceID).(string); ok && wsID != "" {
|
|
return wsID, true
|
|
}
|
|
|
|
// Slow path: resolve directly (should rarely happen — only for routes
|
|
// that don't go through RequireWorkspaceAccess)
|
|
slugOrID := chi.URLParam(r, "slug")
|
|
ws, err := s.resolveWorkspace(slugOrID, currentUser(r))
|
|
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
|
|
}
|
|
|
|
// getWorkspace returns the full workspace object resolved by middleware.
|
|
// Falls back to direct resolution for routes without RequireWorkspaceAccess.
|
|
func (s *Server) getWorkspace(w http.ResponseWriter, r *http.Request) (*models.Workspace, bool) {
|
|
// Fast path: use middleware-resolved ID
|
|
if wsID, ok := r.Context().Value(ctxResolvedWorkspaceID).(string); ok && wsID != "" {
|
|
ws, err := s.store.GetWorkspaceByID(wsID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return nil, false
|
|
}
|
|
if ws != nil {
|
|
return ws, true
|
|
}
|
|
}
|
|
|
|
// Slow path: resolve from URL param
|
|
slugOrID := chi.URLParam(r, "slug")
|
|
ws, err := s.resolveWorkspace(slugOrID, currentUser(r))
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return nil, false
|
|
}
|
|
if ws == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
|
|
return nil, false
|
|
}
|
|
return ws, true
|
|
}
|
|
|
|
// visibleCollectionIDs returns the set of collection IDs the current user can
|
|
// see in the given workspace. Returns nil if the user has "all" access (no
|
|
// filtering needed), or a non-nil slice for "specific" access. Admins and
|
|
// unauthenticated users (fresh install) always get nil (all access).
|
|
func (s *Server) visibleCollectionIDs(r *http.Request, workspaceID string) ([]string, error) {
|
|
user := currentUser(r)
|
|
if user == nil || user.Role == "admin" {
|
|
return nil, nil // No filtering for admins or unauthenticated
|
|
}
|
|
return s.store.VisibleCollectionIDs(workspaceID, user.ID)
|
|
}
|
|
|
|
// requireItemVisible checks that the item's collection is visible to the
|
|
// requesting user. For guests with item-level grants, also verifies that the
|
|
// specific item is granted (not just the collection). Writes a 404 and returns
|
|
// false if not. Callers should invoke this immediately after resolving an item
|
|
// by slug/ID.
|
|
func (s *Server) requireItemVisible(w http.ResponseWriter, r *http.Request, workspaceID string, item *models.Item) bool {
|
|
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return false
|
|
}
|
|
if !isCollectionVisible(item.CollectionID, visibleIDs) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return false
|
|
}
|
|
|
|
// For users with item-level grants (guests or restricted members),
|
|
// collection visibility may come from item-level grants.
|
|
// We need to verify the user actually has a grant on this specific item
|
|
// (not just another item in the same collection).
|
|
// Uses guestResourceFilter which skips this check for members with "all" access.
|
|
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
|
|
if grantErr != nil {
|
|
writeInternalError(w, grantErr)
|
|
return false
|
|
}
|
|
if len(grantedItemIDs) > 0 {
|
|
// If the collection has a full collection grant, the item is visible
|
|
for _, id := range fullCollIDs {
|
|
if id == item.CollectionID {
|
|
return true
|
|
}
|
|
}
|
|
// Check if this collection came from member_collection_access (not grants)
|
|
if workspaceRole(r) != "guest" {
|
|
memberColls, _ := s.store.GetMemberCollectionAccess(workspaceID, currentUserID(r))
|
|
for _, id := range memberColls {
|
|
if id == item.CollectionID {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
// Otherwise, the specific item must be in the granted items list
|
|
for _, id := range grantedItemIDs {
|
|
if id == item.ID {
|
|
return true
|
|
}
|
|
}
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// isItemVisibleToGuest checks if an item is visible given grant-based access,
|
|
// considering both full-collection grants and individual item grants.
|
|
// When fullCollIDs and grantedItemIDs are both nil, always returns true (no grant filtering).
|
|
func (s *Server) isItemVisibleToGuest(r *http.Request, workspaceID string, item *models.Item, fullCollIDs, grantedItemIDs []string) bool {
|
|
if fullCollIDs == nil && grantedItemIDs == nil {
|
|
return true
|
|
}
|
|
// Full collection grant covers all items in the collection
|
|
for _, id := range fullCollIDs {
|
|
if id == item.CollectionID {
|
|
return true
|
|
}
|
|
}
|
|
// Otherwise, the specific item must be in the granted items list
|
|
for _, id := range grantedItemIDs {
|
|
if id == item.ID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// guestResourceFilter returns the full-collection IDs and granted item IDs for
|
|
// the current user if they need item-level grant filtering. Returns nil/nil for:
|
|
// - unauthenticated users
|
|
// - admin users
|
|
// - members with "all" collection access (grants should merge, not replace)
|
|
// For guests: returns direct collection grants as fullCollIDs + item grants.
|
|
// For restricted members: returns member_collection_access + system collections
|
|
// + direct collection grants as fullCollIDs, plus item grants as grantedItemIDs.
|
|
// This ensures item grants are additive to the member's existing access.
|
|
func (s *Server) guestResourceFilter(r *http.Request, workspaceID string) (fullCollIDs, grantedItemIDs []string, err error) {
|
|
user := currentUser(r)
|
|
if user == nil || user.Role == "admin" {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
role := workspaceRole(r)
|
|
|
|
// For workspace members with "all" collection access, item grants should
|
|
// not restrict their existing full visibility.
|
|
if role != "guest" {
|
|
member, err := s.store.GetWorkspaceMember(workspaceID, user.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if member != nil && (member.CollectionAccess == "all" || member.CollectionAccess == "") {
|
|
return nil, nil, nil
|
|
}
|
|
}
|
|
|
|
// Get grant-based resources
|
|
grantCollIDs, grantedItemIDs, err := s.store.GuestVisibleResources(workspaceID, user.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// For guests, grant resources are the only source of access
|
|
if role == "guest" {
|
|
return grantCollIDs, grantedItemIDs, nil
|
|
}
|
|
|
|
// For restricted members ("specific" access), merge their normal
|
|
// member_collection_access + system collections into fullCollIDs so
|
|
// item grants are additive, not a replacement. This is critical:
|
|
// without this merge, a member with access to collection A plus one
|
|
// item grant in collection B would lose collection A in cross-collection
|
|
// queries that use these IDs.
|
|
fullCollSet := make(map[string]bool)
|
|
for _, id := range grantCollIDs {
|
|
fullCollSet[id] = true
|
|
}
|
|
|
|
// Add member_collection_access collections
|
|
memberColls, err := s.store.GetMemberCollectionAccess(workspaceID, user.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, id := range memberColls {
|
|
fullCollSet[id] = true
|
|
}
|
|
|
|
// Add system collections (always visible to members)
|
|
sysColls, err := s.store.ListSystemCollectionIDs(workspaceID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, id := range sysColls {
|
|
fullCollSet[id] = true
|
|
}
|
|
|
|
fullCollIDs = make([]string, 0, len(fullCollSet))
|
|
for id := range fullCollSet {
|
|
fullCollIDs = append(fullCollIDs, id)
|
|
}
|
|
|
|
return fullCollIDs, grantedItemIDs, nil
|
|
}
|
|
|
|
// isCollectionVisible checks if a collection ID is in the visible set.
|
|
// If visibleIDs is nil, all collections are visible.
|
|
func isCollectionVisible(collectionID string, visibleIDs []string) bool {
|
|
if visibleIDs == nil {
|
|
return true
|
|
}
|
|
for _, id := range visibleIDs {
|
|
if id == collectionID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// requireEditPermission checks if the user has edit access to the given item.
|
|
// For regular members (editor/owner), this uses the standard role check.
|
|
// For members with insufficient roles (e.g., viewers), it falls back to
|
|
// grant-based permissions so grants can override the base role.
|
|
// For guests, it resolves the effective permission from grants directly.
|
|
// Returns true if the request should continue, false if it was rejected with a 403.
|
|
func (s *Server) requireEditPermission(w http.ResponseWriter, r *http.Request, workspaceID string, itemID, collectionID string) bool {
|
|
role := workspaceRole(r)
|
|
|
|
// Editors and owners always have edit access
|
|
if role != "guest" && requireRole(r, "editor") {
|
|
return true
|
|
}
|
|
|
|
// For guests and members with insufficient role (e.g., viewers),
|
|
// check grant-based permissions as an override.
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions")
|
|
return false
|
|
}
|
|
|
|
perm, err := s.store.ResolveUserPermission(workspaceID, user.ID, itemID, collectionID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return false
|
|
}
|
|
if permissionLevel(perm) < permissionLevel("edit") {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// resolveWorkspace resolves a workspace by slug or UUID, scoped to the
|
|
// authenticated user's accessible workspaces when a user context is present.
|
|
// Returns nil (not an error) if no workspace is found.
|
|
func (s *Server) resolveWorkspace(slugOrID string, user *models.User) (*models.Workspace, error) {
|
|
// 1. Is it a UUID? Try resolving by ID first, then fall back to slug.
|
|
// A workspace slug could be UUID-shaped (e.g. imported data), so we
|
|
// can't short-circuit here.
|
|
if isUUID(slugOrID) {
|
|
ws, err := s.store.GetWorkspaceByID(slugOrID)
|
|
if ws != nil || err != nil {
|
|
return ws, err
|
|
}
|
|
// Not found by ID — fall through to slug-based resolution
|
|
}
|
|
|
|
// 2. No authenticated user — fall back to global slug lookup
|
|
// (fresh install, or pre-auth paths)
|
|
if user == nil {
|
|
return s.store.GetWorkspaceBySlug(slugOrID)
|
|
}
|
|
|
|
// 3. Admin users — global slug lookup (admins can see all workspaces)
|
|
if user.Role == "admin" {
|
|
return s.store.GetWorkspaceBySlug(slugOrID)
|
|
}
|
|
|
|
// 4. Auth-scoped slug resolution: find workspaces where user is owner or member
|
|
workspaces, err := s.store.GetWorkspacesBySlugForUser(slugOrID, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(workspaces) == 1 {
|
|
return &workspaces[0], nil
|
|
}
|
|
if len(workspaces) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Ambiguous: multiple workspaces match — this should be rare.
|
|
// For now, return the first one. The 409 disambiguation is only needed
|
|
// when we actually have per-owner slug uniqueness (after the unique
|
|
// constraint is changed). Currently slugs are globally unique.
|
|
return &workspaces[0], nil
|
|
}
|
|
|
|
// isUUID is defined in handlers_items.go
|
|
|
|
// 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
|
|
}
|