# catcrafts.net Caddy site block # # Two upstreams: Caddy's file_server for build artifacts, and catcrafts-server # for everything else. Point `root` at the host directory you bind-mount into # the runner as /deploy (the "-v /path/to/webroot:/deploy" in the runner's # config.yaml). # # catcrafts-server speaks PLAINTEXT HTTP/1.1 on localhost — Caddy terminates # TLS. That is also why the backend uses Crafter.Network's ListenerHTTP1 rather # than its HTTP/3 listener: Caddy cannot reverse_proxy to an h3 upstream. # Do not expose port 8081 directly. catcrafts.net { root * /srv/catcrafts.net encode zstd gzip header { Referrer-Policy "strict-origin-when-cross-origin" X-Content-Type-Options "nosniff" -Server # HSTS. Caddy redirects http->https but does NOT send this header on # its own, so without it a first visit over http is still interceptable # and every later one is only as safe as the redirect. A shop taking # card payments should not be relying on a redirect. # # includeSubDomains commits EVERY catcrafts.net subdomain to HTTPS — # www and forgejo are both on TLS here, so it holds. Drop that token if # a subdomain ever has to serve plaintext. `preload` is deliberately # NOT set: submission to the browser preload list is months to undo, # and it should be a decision, not a side effect of this file. Strict-Transport-Security "max-age=31536000; includeSubDomains" # CSP. Defence in depth rather than the primary control — markup is # built through Catcrafts.Shared:Html, where escaping is enforced by # the type system and forgetting is a compile error. What this adds is # the damage limitation that escaping cannot provide: # # form-action 'self' the checkout form cannot be retargeted at # another origin — the directive that matters # most on a page that collects an address # frame-ancestors no clickjacking the buy button # base-uri 'none' an injected cannot re-point every # relative script src on the page # object-src 'none' no plugin content, ever # # 'unsafe-inline' in script-src is a known and bounded compromise: the # geo price hint (kGeoPriceHintScript) must run before first paint to # set a class on without a flash, so it is inline by design. # Removing it means hashing that constant here and re-hashing on every # edit — silently breaking the hint when someone forgets. To tighten # this properly, move the script to a real file and give it a nonce. # 'wasm-unsafe-eval' is what the WASM runtime needs to compile the # module; it does not enable eval() for JavaScript. # # img-src and media-src allow https: because a post whose media mirror # failed still points at the source instance's URL (see Media::Describe) # — locking those to 'self' would blank exactly the pictures a post is # talking about. Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https:; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'" } # ── cross-origin isolation, scoped ──────────────────────────────────── # # The WASM runtime needs a cross-origin-isolated context (SharedArrayBuffer # / threads), and these three headers are what provide it. They are NOT # optional on a page that boots the module — without them it loads and the # runtime fails. # # But they are scoped to the paths that actually load it, rather than applied # site-wide. COEP: require-corp blocks every cross-origin subresource that # does not opt in, so applying it to pages that have no wasm would constrain # them for no benefit — and the shop's payment pages later must not inherit # that restriction. @isolated path /demos/* /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \ /catcrafts-head.js /files.json /variants.json /*.wgsl header @isolated { Cross-Origin-Opener-Policy "same-origin" Cross-Origin-Embedder-Policy "require-corp" Cross-Origin-Resource-Policy "same-origin" } # Subresources an isolated document pulls in must carry CORP themselves. # The icons need it for the same reason the stylesheet does: "/" boots the # wasm, so its icon fetches happen inside a require-corp document. # # -precomposed is listed here even though the rewrite below folds it onto # apple-touch-icon.png: Caddy's directive order runs `header` BEFORE # `rewrite`, so this matcher sees the URI as it arrived. (`handle` runs after, # which is why @static does NOT need to list it.) Drop the path here and the # probe still 200s, just bare. header /styles.css Cross-Origin-Resource-Policy "same-origin" @icons path /favicon.svg /favicon.ico /apple-touch-icon.png \ /apple-touch-icon-precomposed.png header @icons Cross-Origin-Resource-Policy "same-origin" # ── analytics, two tiers ────────────────────────────────────────────── # # GoAccess reports rebuilt hourly by catcrafts-analytics.timer from the # access logs below. Server-side only: no client-side analytics anywhere # on the site, per the privacy policy (request logging only). # # /analytics/ is public and censored - visitor IPs are anonymized at # ingest, there is no host panel, and sensitive paths never enter its DB # (see CENSOR_RE in deploy/catcrafts-analytics). /analytics/private/ is # the uncensored report. Generate the hash with `caddy hash-password`. redir /analytics /analytics/ 308 redir /analytics/private /analytics/private/ 308 # The privacy policy's "view previous versions" promise. A redirect so # the policy text stays short and survives repo or path moves - update # the target here, never the policy wording. redir /legal/privacy/history https://forgejo.catcrafts.net/Catcrafts/catcrafts.net/commits/branch/master/shared/interfaces/Catcrafts.Shared-Content.cppm 302 handle_path /analytics/private/* { basic_auth { jorijn } root * /var/www/analytics-private header Cache-Control "private, no-store" file_server } handle_path /analytics/* { root * /var/www/analytics header Cache-Control "public, max-age=600" file_server } # ── build artifacts: served from disk ───────────────────────────────── # # file_server does sendfile, precompressed variants and range requests far # better than the backend would. `precompressed` serves the .zst / .gz # siblings the CI build produces, so the ~800 KB module is never recompressed # per request. Cache-busted by the ?v= in index.html. # # CAVEAT for the icons in here: nothing cache-busts them. Clients ask for # /favicon.svg, /favicon.ico and /apple-touch-icon.png by bare path, so an # immutable year is exactly how long a redrawn icon takes to reach a repeat # visitor. Long-standing for favicon.svg; the rasters inherit it deliberately # rather than diverge. Shorten all three together if that ever bites. # # The rewrite is Safari's legacy Home Screen probe, from before it honoured # . What it asks for is byte-identical to # apple-touch-icon.png, so it is folded onto that rather than committed twice # — a second copy would also ride in the wasm bundle's VFS and be fetched at # every boot for nothing. @static below matches the rewritten path. rewrite /apple-touch-icon-precomposed.png /apple-touch-icon.png @static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \ /catcrafts-head.js /files.json /variants.json /styles.css \ /favicon.svg /favicon.ico /apple-touch-icon.png \ /robots.txt /*.wgsl /*.jpg /posts.json /rates.json handle @static { header Cache-Control "public, max-age=31536000, immutable" file_server { precompressed zstd gzip } } # ── mirrored post media ────────────────────────────────────────────── # # Deliberately NOT under the web root: that directory is mirrored with # `rsync --delete` on every deploy, and this media is not always # reproducible — if a source instance deletes a file, our copy is the only # one left. Living on the app mount puts it physically outside the delete. # # Filenames are the content hash, so a changed file gets a new name and the # immutable cache lifetime is honest. handle_path /media/* { root * /srv/catcrafts-app/media header Cache-Control "public, max-age=31536000, immutable" file_server } # ── everything else: server-rendered ───────────────────────────────── # # Pages, /feed.xml, /sitemap.xml and later /api/*. The backend sets its own # Cache-Control and returns real status codes — a 404 for an unknown path and # a 301 for the retired /blog URLs, which a client-side router cannot do. # Checkout rate limiting is per-peer, and this block is what makes that # possible: reverse_proxy APPENDS the real client address to # X-Forwarded-For, and the backend reads the rightmost entry (see # ClientAddressFromForwarded — the leftmost is whatever the client claimed). # That is only sound while nothing but Caddy can reach 8081, which is why # the backend binds loopback and why the warning at the top of this file # says not to expose the port. # # Caddy's own rate_limit directive is a third-party module and is NOT in a # standard build — adding it here without rebuilding Caddy stops the server # from starting. If volume ever justifies limiting at the edge, build Caddy # with github.com/mholt/caddy-ratelimit first. handle { reverse_proxy 127.0.0.1:8081 { health_uri /api/healthz } } # If the backend is down, fall back to the static wasm shell so the site # degrades to a client-rendered app rather than a bare Caddy error page. # This must be handle_errors, not handle_response: handle_response only # fires on responses an upstream actually sent, and a backend that is not # running refuses the connection — that is an error, which only # handle_errors sees (verified against a dead port). The error status is # kept, so a crawler sees the honest 502 while a human gets the working # client-rendered app; only SSR and real 404s are lost. handle_errors 502 503 504 { rewrite * /index.html header Cache-Control "no-store" file_server } # ── access log ──────────────────────────────────────────────────────── # # Feeds the GoAccess analytics (see deploy/catcrafts-analytics). Retention # sized for that: ~15 MB/day means a roll roughly weekly; 52 compressed # rolls ~ a year of raw logs (~6 MB each gzipped). roll_keep_for must be # explicit — lumberjack's default silently deletes after 90 days. log { output file /var/log/caddy/catcrafts.net.log { roll_size 100MiB roll_keep 52 roll_keep_for 8760h } } } # www -> apex. DNS A/AAAA for www point at this box; Caddy obtains the # certificate automatically. 308 to match the redirs above. www.catcrafts.net { redir https://catcrafts.net{uri} 308 }