Something went wrong. Try again.
forked from scottlanoue.com/atmosphere-mail
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import ( "context" "net/http" "os" "path" "path/filepath" "strings" "time"
"comail/internal/buildinfo" "comail/internal/featureflags")
// htmlModTime returns the ModTime to surface to http.ServeContent for// HTML responses. Nix builds zero out file mtimes for reproducibility,// so info.ModTime() reports 1970-01-01 — paired with Cache-Control:// no-cache that produces a revalidation loop where the browser sends// If-Modified-Since: 1970-01-01 and ServeContent always replies 304,// so members serve a stale HTML shell forever (Scott reported this// 2026-05-16: navbar update didn't show up across deploys).//// Using the build date as the ModTime fixes both ends: each deploy// bumps the timestamp, the next If-Modified-Since revalidates against// the new value, the browser gets a fresh shell that references the// new hashed JS bundles. Within a deploy the cache still works —// nothing has changed yet.//// Falls back to time.Now() when BuildDate is unset (dev builds, tests)// so behavior in those cases is "always fresh" rather than "always// stale via the epoch sentinel."func htmlModTime() time.Time { if buildinfo.BuildDate == "" { return time.Now() } t, err := time.Parse(time.RFC3339, buildinfo.BuildDate) if err != nil { return time.Now() } return t}
// staticHandler serves the `web-frontend/dist/` tree built by Astro.//// Phase 1 of the comail rewrite (decision doc 2026-05-13) introduced// the handler with an /account-only allowlist. Phase 2 (chainlink// #451) added /signup and the /enroll/* wizard surfaces. Phase 3// (internal ref) added the marketing landing, legal/security pages,// docs tree, and a static 404 fallback for browser GETs.//// Ported routes://// / → dist/index.html (marketing landing).// Only GET/HEAD; POST falls through so the// Templ `/signup` POST endpoint (registered// as a separate route in the relay's mux) is// not intercepted.// /account → dist/account/index.html.// /account/{anything}/ → /account/{anything}/index.html when present.// /account/domains/{x} → dist/account/domains/_/index.html (single// SPA shell; JS reads the {x} segment from// window.location).// /signup → dist/signup/index.html (GET/HEAD only; POSTs// continue to reach the Templ handle-only form).// /enroll/verify → dist/enroll/verify/index.html (GET/HEAD only;// POSTs continue to the Templ verify handler).// /enroll/credentials/show// → dist/enroll/credentials/show/index.html.// Requests carrying `?format=json` or// `Accept: application/json` fall through to// the Go handler which serves the credentials// JSON (the Astro shell fetches this variant).// /enroll/attest/published// → dist/enroll/attest/published/index.html.// /privacy[/] → dist/privacy/index.html.// /security[/] → dist/security/index.html.// /terms[/] → dist/terms/index.html (internal ref).// /aup[/] → dist/aup/index.html (internal ref).// /about[/] → dist/about/index.html (internal ref).// /faq[/] → dist/faq/index.html (internal ref).// /pricing[/] → dist/pricing/index.html (internal ref).// /docs[/] → dist/docs/index.html.// /docs/{slug}[/] → dist/docs/{slug}/index.html for the known// doc slugs only. Unknown slugs fall through// to the Templ docs renderer which has its// own 404 path.// /_astro/* → immutable hashed assets (Cache-Control:// public, max-age=31536000, immutable).// /favicon.ico → dist/favicon.ico (if present).//// Routes the handler MUST leave to Templ even though they share a// prefix://// /account/domains → 301 redirect to /account (internal ref// collapsed the standalone list page).// /account/domains/add → add-domain wizard.// /account/domains/{x}/{anything} → POST endpoints (regenerate-key,// test-send, attestation publish,// verify, label polling, etc.).// /signup (POST) → Templ handle-only kickoff form.// /enroll/verify (POST) → Templ DNS verify handler.// /enroll/attest/start, /enroll/attest/callback → Templ OAuth dance.// /enroll (and other unallowlisted /enroll/* paths) → Templ wizard.//// Browser-GET 404 fallback: unmatched GET/HEAD requests for paths that// would have a browser-renderable response receive dist/404.html when// it exists. API and POST paths fall through unmodified so JSON// endpoints keep returning JSON. The fallback is keyed off the// `Accept` header — clients that don't accept text/html get the// pass-through behavior.//// When the dist root doesn't exist on disk, the handler degrades to a// pure pass-through so cmd/web keeps serving the Templ routes — a// missing build artifact must not take down the rest of the binary.type staticHandler struct { root string next http.Handler enabled bool // paidTiersOn reports whether the FeaturePaidTiers gate is on, // evaluated LIVE per request (internal ref) so a runtime override // un-hides /pricing without a redeploy. Defaults to the OpenFeature // client read; tests inject a stub so the gated path is exercised // without touching the process-global provider. paidTiersOn func(context.Context) bool}
// defaultPaidTiersOn is the production live read for the /pricing gate.func defaultPaidTiersOn(ctx context.Context) bool { return featureflags.On(ctx, featureflags.PaidTiers)}
// newStaticHandler builds the handler. `root` is the absolute path to// the `web-frontend/dist/` directory; `next` is the Templ-served// handler that takes over for unallowlisted paths.//// The /pricing gate reads paid_tiers live via paidTiersOn, defaulted here// to the OpenFeature client; tests inject a stub on the field directly.func newStaticHandler(root string, next http.Handler) *staticHandler { enabled := false if root != "" { if info, err := os.Stat(root); err == nil && info.IsDir() { enabled = true } } return &staticHandler{ root: root, next: next, enabled: enabled, paidTiersOn: defaultPaidTiersOn, }}
const domainDetailShell = "account/domains/_/index.html"
// domainVerifyShell is the Astro shell for the DNS-publish + verify// wizard (internal ref, slice 5b of #574). Same single-sentinel// pattern as domainDetailShell: Astro builds one HTML file at// dist/account/domains/_/verify/index.html and the staticHandler// rewrites every /account/domains/{x}/verify GET to it; the actual// {domain} segment is read client-side from the URL.const domainVerifyShell = "account/domains/_/verify/index.html"
// docsSlugAllowlist is the legacy per-slug allowlist that used to gate// /docs/{slug}. Replaced (internal ref) by a filesystem existence// check below — adding a new docs page now requires zero Go changes,// just drop the markdown under web-frontend/src/content/docs/docs/// and let Astro/Starlight emit dist/docs/{slug}/index.html. The// handler stats that file on each request and falls through to the// Templ 404 if it's missing, which is safe because:// - urlPath is already path.Clean'd and `..`-rejected upstream// - the slug rejection catches anything with a `/` so nested paths// can't reach the os.Stat call// - serveFile reads only inside h.root via filepath.Join, so even// if a slug somehow contained shell metacharacters they'd resolve// under the dist root or fail to open//// The previous allowlist required every new page to ship its own Go// change — caught us on PR #888 (Starlight spike, only /docs/dns// allowlisted) and again on PRs #906/#907/#910 (glossary, categories,// quickstart-{node,python,go} all 404'd until this fix).
// privacySlugs is the set of /privacy/{slug} paths Astro emits. Today// only data-deletion exists (internal ref deletion-executed email// link target); add new entries as nested privacy pages land under// web-frontend/src/pages/privacy/.var privacySlugs = map[string]bool{ "data-deletion": true,}
func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !h.enabled { h.next.ServeHTTP(w, r) return }
// Normalize the URL path. `path.Clean` collapses traversal // segments (`a/../b` → `b`); after cleaning we also reject any // remaining `..` so a percent-encoded payload that survives // browser/proxy normalization can't escape the dist root. urlPath := path.Clean(r.URL.Path) if urlPath == "." { urlPath = "/" } if strings.Contains(urlPath, "..") { h.next.ServeHTTP(w, r) return }
switch { case urlPath == "/": // Marketing landing. No isBrowserGET gate (matches /privacy // /security /terms /aup /faq /about /account/* etc) — chainlink // #506 rationale: scripted clients (curl, link previewers, // uptime probes, OG image scrapers) should reach the page // unconditionally. POST is method-checked below so a POST to // `/` falls through to the Templ handler's 405. Pre-#458 Phase // 5 the gate kept the Templ MarketingLanding alive for non- // browser GETs; with /api/v1/signup carrying the OAuth kickoff // the Astro shell is the canonical render for every client // shape that accepts HTML at all. if r.Method != http.MethodGet && r.Method != http.MethodHead { h.next.ServeHTTP(w, r) return } h.serveFile(w, r, "index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/privacy" || urlPath == "/privacy/": // No isBrowserGET gate (internal ref): /privacy is a pure // static page with no POST analog anywhere in the stack. // Gating on Accept made scripted clients (curl, link // previewers, uptime probes, deletion-email link clicks // through privacy-respecting clients that omit Accept) 404 // while real browsers got the page. The route must be // unconditionally available. h.serveFile(w, r, "privacy/index.html", "text/html; charset=utf-8", "no-cache") return
case strings.HasPrefix(urlPath, "/privacy/"): // Nested /privacy/{slug} pages — gated to a known-slugs map // (mirrors the /docs/{slug} pattern) so unknown slugs fall // through to the Templ handler / 404 rather than disclosing // the dist tree layout. /privacy/data-deletion (chainlink // #471) is the only nested page today; new ones add to // privacySlugs above. rest := strings.TrimPrefix(urlPath, "/privacy/") rest = strings.TrimSuffix(rest, "/") if rest == "" || strings.Contains(rest, "/") || !privacySlugs[rest] { h.next.ServeHTTP(w, r) return } h.serveFile(w, r, "privacy/"+rest+"/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/security" || urlPath == "/security/": // No isBrowserGET gate (internal ref): same reasoning as // /privacy above. /security publishes the disclosure contact // and PGP-key pointer; making it unreachable to scripted // clients defeats the purpose of having a published policy. h.serveFile(w, r, "security/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/terms" || urlPath == "/terms/": // Phase 3.5 / internal ref: legal/info static pages move to // Astro. No isBrowserGET gate (matches /privacy rationale — // internal ref). Templ source in web/templates/enroll.templ // remains as the fallback target when the Astro dist is absent // (serveFile falls through on stat error). h.serveFile(w, r, "terms/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/aup" || urlPath == "/aup/": h.serveFile(w, r, "aup/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/about" || urlPath == "/about/": h.serveFile(w, r, "about/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/faq" || urlPath == "/faq/": h.serveFile(w, r, "faq/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/pricing" || urlPath == "/pricing/": // internal ref: public /pricing page. Same Astro-only pattern // as /faq /about /aup — no isBrowserGET gate, no method check // beyond what serveFile enforces. Marketing surface; must be // reachable from link previewers and curl without an Accept // header (same /privacy rationale per internal ref). // // Paid-tier kill switch: when FeaturePaidTiers is off (the // default), fall through to next so the mux's http.NotFound // stub (cmd/web/public.go) returns 404. The Astro shell still // ships in dist/ — flipping ATMOS_FEATURE_PAID_TIERS=true at // runtime un-hides the page without a redeploy. if !h.paidTiersOn(r.Context()) { h.next.ServeHTTP(w, r) return } h.serveFile(w, r, "pricing/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/docs" || urlPath == "/docs/": // No isBrowserGET gate (internal ref follow-up — matches the // /privacy/terms/security/aup/about/faq pattern per chainlink // #506). /docs is public reference content; link previewers, // uptime probes, OG scrapers, and any client that omits Accept // must still reach the Astro/Starlight render rather than the // Templ fallback. Method-check below so POST falls through. if r.Method != http.MethodGet && r.Method != http.MethodHead { h.next.ServeHTTP(w, r) return } h.serveFile(w, r, "docs/index.html", "text/html; charset=utf-8", "no-cache") return
case strings.HasPrefix(urlPath, "/docs/"): // One-segment slugs only — nested paths (none exist today, // but if a future doc grows a sub-page) fall through. The // previous version of this branch checked rest against a // hardcoded docsSlugs map; internal ref replaced that with // a filesystem stat so docs pages can ship without a paired // Go change. See the comment block above for why this is // safe relative to path traversal. rest := strings.TrimPrefix(urlPath, "/docs/") rest = strings.TrimSuffix(rest, "/") if rest == "" || strings.Contains(rest, "/") { h.next.ServeHTTP(w, r) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { h.next.ServeHTTP(w, r) return } candidate := filepath.Join(h.root, "docs", rest, "index.html") if _, err := os.Stat(candidate); err != nil { h.next.ServeHTTP(w, r) return } h.serveFile(w, r, "docs/"+rest+"/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/account" || urlPath == "/account/": h.serveFile(w, r, "account/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/account/api-keys" || urlPath == "/account/api-keys/": // internal ref Phase 4: API keys management. Astro shell // fetches /api/v1/account/api-keys for the list and posts JSON // to /api/v1/account/regenerate for rotation. The Templ // AccountAPIKeysList renderer + handleAPIKeys handler were // deleted alongside this wiring. /account/regenerate (the // legacy form-POST handler) stays for back-compat. h.serveFile(w, r, "account/api-keys/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/account/webhooks" || urlPath == "/account/webhooks/": // internal ref: outbound webhooks management. Astro shell // (web-frontend/src/pages/account/webhooks.astro) fetches // /api/v1/account/webhooks for the endpoint list + delivery // log, and POSTs/PATCHes/DELETEs the same prefix for CRUD, // rotate-secret, and replay actions. The Templ-style inline- // HTML handler that previously claimed this path was deleted. h.serveFile(w, r, "account/webhooks/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/account/api-keys/regenerated" || urlPath == "/account/api-keys/regenerated/": // internal ref: PRG reveal shell. Three rotation paths // (recovery flow, /account self-service, operator admin) stash // the freshly-minted key in the shared apiKeyReveal cache and // 303 here with ?token=...; this shell fetches // /api/v1/account/api-key-reveal to display it. h.serveFile(w, r, "account/api-keys/regenerated/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/account/marque-consent" || urlPath == "/account/marque-consent/": // internal ref: Marque-detected consent screen. When the // per-domain page sees marqueDetected + attestationPublished + // !marqueDNSPublished, it redirects here with ?domain=<d>. // Two CTAs: "Yes" kicks the existing /api/v1/account/domains/{d}/publish-marque-dns // OAuth flow; "No" sets a per-domain localStorage flag and // returns to /account/domains/{d} (the inline banner stays // available as a fallback for members who change their mind). h.serveFile(w, r, "account/marque-consent/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/account/email" || urlPath == "/account/email/": // internal ref Phase 3: change-contact-email surface. Astro // shell fetches /api/v1/account/email (GET to populate form, // POST to stage a pending change). POST never traverses // staticHandler — it targets the /api/v1/* URL — so this // branch only intercepts GET. No isBrowserGET gate because // the Templ handler that previously claimed /account/email is // gone; if the dist file is missing we fall through to the // http.NotFound stub on cmd/web's mux rather than back to // stale HTML (same fail-loud rationale as deliverability). h.serveFile(w, r, "account/email/index.html", "text/html; charset=utf-8", "no-cache") return
case urlPath == "/account/deliverability" || urlPath == "/account/deliverability/": // internal ref Phase 2: per-domain deliverability view — // Astro shell replaces the legacy Templ DeliverabilityPage. // Reads ?domain=<x> client-side and fetches // /api/v1/account/domains/<x>/deliverability. No isBrowserGET // gate (the page has no POST analog; auth gating happens at // the JSON API layer). The Templ handler at // RecoverHandler.handleDeliverability was deleted in the same // commit; if dist is missing this 404s rather than falling // back to stale HTML — same intentional fail-loud as the // legal-page retirement (internal ref Phase 1). h.serveFile(w, r, "account/deliverability/index.html", "text/html; charset=utf-8", "no-cache") return
// /account/settings/delete (warning page) and // /account/settings/delete/scheduled (cooldown landing) ship as // part of internal ref / Gitea #655 (GDPR Article 17 self- // service account deletion). Only browser GET/HEAD lands on the // Astro shells — POSTs on /account/settings/delete/request and // /account/settings/delete/reactivate continue past the static // handler so the backend form handlers (registered separately // in cmd/relay) own the destructive action paths. Same goes for // GET /account/settings/data-export which streams the JSON // archive download from the Go handler. case (urlPath == "/account/settings/delete" || urlPath == "/account/settings/delete/") && (r.Method == http.MethodGet || r.Method == http.MethodHead): h.serveFile(w, r, "account/settings/delete/index.html", "text/html; charset=utf-8", "no-cache") return
case (urlPath == "/account/settings/delete/scheduled" || urlPath == "/account/settings/delete/scheduled/") && (r.Method == http.MethodGet || r.Method == http.MethodHead): h.serveFile(w, r, "account/settings/delete/scheduled/index.html", "text/html; charset=utf-8", "no-cache") return
// Phase 2 (internal ref) — Astro shells for the /enroll/* wizard // surfaces. POSTs on these paths (DNS verify) MUST continue to reach // the Templ handlers in cmd/relay; only GETs land on Astro. // // /signup was previously intercepted here too, but the consolidation // PR merged the GET form into the landing at /. GET /signup now // falls through to SignupHandler.handleEntry, which 302-redirects // to /. POST /signup still hits the same handler (OAuth kickoff). case (urlPath == "/enroll/verify" || urlPath == "/enroll/verify/") && (r.Method == http.MethodGet || r.Method == http.MethodHead): h.serveFile(w, r, "enroll/verify/index.html", "text/html; charset=utf-8", "no-cache") return
case (urlPath == "/verify-email-result" || urlPath == "/verify-email-result/") && (r.Method == http.MethodGet || r.Method == http.MethodHead): // internal ref Phase 5: /verify-email result page. The Go // handlers at /verify-email (legacy + account-first) process the // token, then 303-redirect here with `?state=success|missing|bad|expired`. // Static Astro shell reads the state client-side and renders the // matching copy. The Templ VerifyEmailPage renderer was deleted // alongside this wiring. h.serveFile(w, r, "verify-email-result/index.html", "text/html; charset=utf-8", "no-cache") return
case (urlPath == "/enroll/credentials/show" || urlPath == "/enroll/credentials/show/") && (r.Method == http.MethodGet || r.Method == http.MethodHead): // The Go handler co-located at /enroll/credentials/show // honors `?format=json` and an Accept: application/json // header. When neither is set we serve the Astro shell, which // then fetches the JSON variant to populate. if r.URL.Query().Get("format") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json") { h.next.ServeHTTP(w, r) return } h.serveFile(w, r, "enroll/credentials/show/index.html", "text/html; charset=utf-8", "no-cache") return
case (urlPath == "/enroll/attest/published" || urlPath == "/enroll/attest/published/") && (r.Method == http.MethodGet || r.Method == http.MethodHead): h.serveFile(w, r, "enroll/attest/published/index.html", "text/html; charset=utf-8", "no-cache") return
case strings.HasPrefix(urlPath, "/account/domains/"): // Responsibilities here: // - "/account/domains/" (trailing slash, no name) → fall through. // - "/account/domains/add" → serve Astro shell on GET/HEAD, // fall through on POST (internal ref Phase 5: the Templ // AccountDomainAddForm + AccountDomainAddBlocked renderers // were retired; the shell reads canAddDomain off // /api/v1/account to render form vs. blocked banner). // - "/account/domains/cancel-pending" → fall through (JSON // POST handler for internal ref; treating it as a domain // name would serve the SPA shell with text/html and break // the SPA's apiFetch JSON.parse with "string did not match // the expected pattern" in Safari). // - "/account/domains/{x}" → serve the SPA shell. // - "/account/domains/{x}/{rest}" → fall through (POST endpoints). rest := strings.TrimPrefix(urlPath, "/account/domains/") if rest == "cancel-pending" || rest == "" { h.next.ServeHTTP(w, r) return } if rest == "add" { if r.Method == http.MethodGet || r.Method == http.MethodHead { h.serveFile(w, r, "account/domains/add/index.html", "text/html; charset=utf-8", "no-cache") return } h.next.ServeHTTP(w, r) return } if strings.Contains(rest, "/") { // Nested sub-path — most still belong to the Go mux (POST // endpoints like /recheck, /relabel, /test-send, etc.). One // exception: GET /account/domains/{d}/verify is the // Astro-shell wizard (internal ref, slice 5b). We // intercept it here on GET/HEAD so the static dist serves // the shell; POST keeps falling through to the Go // AccountHandler.handleDomainVerify (DNS-resolve work stays // in Go). // // Match shape: rest = "{domain}/verify" exactly. Anything // further (e.g. "{domain}/verify/something") stays a fall- // through so a future sub-path doesn't accidentally get // served the shell. if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasSuffix(rest, "/verify") && strings.Count(rest, "/") == 1 { h.serveFile(w, r, domainVerifyShell, "text/html; charset=utf-8", "no-cache") return } h.next.ServeHTTP(w, r) return } h.serveFile(w, r, domainDetailShell, "text/html; charset=utf-8", "no-cache") return
case strings.HasPrefix(urlPath, "/_astro/"): // Astro emits hashed filenames here; they're safe to cache // for a year. Strip the leading slash to form a dist-relative // path; reject empties (`/_astro/` with no filename). rel := strings.TrimPrefix(urlPath, "/") if rel == "_astro/" || rel == "_astro" { h.next.ServeHTTP(w, r) return } h.serveFile(w, r, rel, "", "public, max-age=31536000, immutable") return
case urlPath == "/favicon.ico": // Optional — only serve when Astro emitted one. Falling // through lets the Templ chrome's inline-SVG favicon answer. full := filepath.Join(h.root, "favicon.ico") if _, err := os.Stat(full); err == nil { h.serveFile(w, r, "favicon.ico", "image/x-icon", "public, max-age=86400") return } h.next.ServeHTTP(w, r) return
case isTopLevelPublicImage(urlPath): // Astro copies web-frontend/public/* to the dist root // unhashed. Browsers reach those assets at /<name>.<ext> // (e.g. /cammie.webp, /cammie.png). The set of safe // passthrough extensions is image-only on purpose: anything // that could be MIME-sniffed to text/html or // application/javascript belongs behind /_astro/* (hashed, // long-cache) or a dedicated route case. serveFile falls // through when the file is missing, so an asset that was // never built doesn't masquerade as 404 here. rel := strings.TrimPrefix(urlPath, "/") h.serveFile(w, r, rel, "", "public, max-age=86400") return }
h.next.ServeHTTP(w, r)}
// isTopLevelPublicImage reports whether urlPath is a single-segment// request whose extension is in the image-only allowlist. Mirrors// what Astro emits from web-frontend/public/ to the dist root.//// The allowlist is intentionally narrow: MIME-sniffing arbitrary// top-level files (especially text/* or application/javascript) would// make this case an XSS vector if a future PR landed an attacker-// controlled name in public/. Add a new extension only after// auditing that http.ServeContent's sniff for that extension is// safe-by-default.func isTopLevelPublicImage(urlPath string) bool { if !strings.HasPrefix(urlPath, "/") { return false } name := strings.TrimPrefix(urlPath, "/") if name == "" || strings.Contains(name, "/") { return false } ext := strings.ToLower(filepath.Ext(name)) switch ext { case ".webp", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".avif", ".ico": return true } return false}
// serveFile resolves `rel` against the dist root and serves it with// the supplied cache headers. If the file doesn't exist or escapes// the root (defense in depth — path.Clean already collapsed `..`),// the request falls through to the Templ handler so a broken build// surfaces as "this page is unreachable" rather than 404 noise on// every request.func (h *staticHandler) serveFile( w http.ResponseWriter, r *http.Request, rel string, contentType string, cacheControl string,) { full := filepath.Join(h.root, rel)
// Defense in depth: confirm the resolved absolute path is still // under h.root. `filepath.Join` already collapses `..` segments // but a relative root could produce a surprising result, so we // re-check after resolving symlinks via filepath.EvalSymlinks // when available (and fall back to the lexical check otherwise). rootAbs, err := filepath.Abs(h.root) if err != nil { h.next.ServeHTTP(w, r) return } fullAbs, err := filepath.Abs(full) if err != nil { h.next.ServeHTTP(w, r) return } if !strings.HasPrefix(fullAbs+string(filepath.Separator), rootAbs+string(filepath.Separator)) && fullAbs != rootAbs { h.next.ServeHTTP(w, r) return }
info, err := os.Stat(fullAbs) if err != nil || info.IsDir() { h.next.ServeHTTP(w, r) return }
f, err := os.Open(fullAbs) if err != nil { h.next.ServeHTTP(w, r) return } defer f.Close()
if contentType != "" { w.Header().Set("Content-Type", contentType) } if cacheControl != "" { w.Header().Set("Cache-Control", cacheControl) } // Pick the right ModTime by cache policy: // - no-cache (HTML shells) → use buildinfo.BuildDate so each // deploy invalidates the cached shell. info.ModTime() is the // Nix sentinel (1970-01-01) and produces an unbreakable 304 loop. // - everything else (immutable hashed assets) → keep info.ModTime(); // hash in the filename already enforces freshness on the asset // side, and the immutable cache directive means browsers won't // revalidate at all. modTime := info.ModTime() if cacheControl == "no-cache" { modTime = htmlModTime() } http.ServeContent(w, r, fullAbs, modTime, f)}