The keepsake download navigates a hidden, same-origin iframe (deliberately: a top-level navigation to a 404/429 would unload the PWA). Caddy stamped a site-wide `X-Frame-Options: DENY` that also covered the proxied `/api/*`. Blink hands a `Content-Disposition: attachment` response to the download manager at the network layer, so Chromium never noticed. WebKit enforces XFO on the frame navigation first and aborts the load — so on iOS Safari, the app's primary platform, tapping Download did nothing at all, silently. Carve the two export endpoints out to SAMEORIGIN, which still blocks cross-origin framing. Implemented as two disjoint matchers rather than an override: Caddy applies the FIRST `header` directive outermost, so it wins on write and a later, more specific `header` is silently ignored (verified against the running test stack). Also close the test gap that let this ship: - `06-export` ran on chromium-desktop only; add it to `webkit-iphone`, the only engine that enforces XFO on the download frame. - No test in the suite ever clicked a download button — every archive assertion used Node `fetch`, which has no frame and no XFO enforcement. Add a spec that clicks it and awaits a real `download` event. Verified falsifiable: with the blanket DENY reinstated it fails and reports the WebKit refusal as the cause. - Fix `ExportPage`'s card-scoped locators, which matched nothing: the cards carry `class="card p-5"` (a Tailwind `@apply` component class), never the `rounded-xl` the page object looked for. This had left the "shows enabled download buttons" test red on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
57 lines
2.5 KiB
Caddyfile
57 lines
2.5 KiB
Caddyfile
{$DOMAIN} {
|
|
# Compress everything EXCEPT the SSE stream — gzip buffering delays
|
|
# "real-time" likes/comments until the ~30s keep-alive tick.
|
|
@compressible not path /api/v1/stream
|
|
encode @compressible zstd gzip
|
|
|
|
# Site-wide security headers (defense-in-depth). HSTS is free since Caddy
|
|
# already terminates TLS. nosniff also covers all of /media/*.
|
|
header {
|
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
X-Content-Type-Options "nosniff"
|
|
Referrer-Policy "strict-origin-when-cross-origin"
|
|
}
|
|
|
|
# X-Frame-Options: DENY everywhere EXCEPT the keepsake download endpoints, which
|
|
# are navigated in a HIDDEN, SAME-ORIGIN iframe so a 404/429 can't unload the PWA
|
|
# (see frontend/src/routes/export/+page.svelte). WebKit enforces XFO *before*
|
|
# honouring Content-Disposition, so a blanket DENY makes the download silently do
|
|
# nothing on iOS Safari — the app's primary platform. SAMEORIGIN still blocks
|
|
# cross-origin framing.
|
|
#
|
|
# Split into two disjoint matchers rather than an override: Caddy applies the
|
|
# FIRST header directive outermost, so it wins on write — a later, more specific
|
|
# `header` would be silently ignored.
|
|
@framable path /api/v1/export/zip /api/v1/export/html
|
|
@not_framable not path /api/v1/export/zip /api/v1/export/html
|
|
header @framable X-Frame-Options "SAMEORIGIN"
|
|
header @not_framable X-Frame-Options "DENY"
|
|
|
|
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
|
|
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
|
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
|
|
|
# Preview/thumbnail images. These are now served by the app through a
|
|
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation
|
|
# can revoke access; direct /media/previews|thumbnails is 404-blocked at the app.
|
|
# Privately cacheable for a short window (the app sets the same header; this is the
|
|
# edge carve-out from the blanket no-store below). Kept short so a moderated image
|
|
# stops being served to a direct-URL holder promptly.
|
|
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
|
header @media_api Cache-Control "private, max-age=300"
|
|
|
|
# API — never cache, EXCEPT the gated image routes above.
|
|
@api {
|
|
path /api/*
|
|
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
|
}
|
|
header @api Cache-Control "no-store"
|
|
|
|
# Route API and media requests to the Rust backend
|
|
reverse_proxy /api/* app:3000
|
|
reverse_proxy /media/* app:3000
|
|
|
|
# Everything else goes to SvelteKit frontend
|
|
reverse_proxy frontend:3001
|
|
}
|