// 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=. // 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= client-side and fetches // /api/v1/account/domains//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 /. // (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) }