Skip to main content

max / makenotwork

Embed the 404 and 500 error pages in the binary The branded error pages were files deploy.sh scp'd to /opt/makenotwork/error-pages on every deploy, read off disk by Caddy's handle_errors. That let pages carrying the copy and brand glyphs drift a release behind the site they front. They are include_str! constants served at /__errors/ now, which Caddy proxies to, so they ship with the binary. 502.html stays on disk and is deliberately unreachable through the route: it is the app-is-down page, so the app cannot be what serves it. Sando already ships error-pages/ as a release sibling for that one case.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-26 14:59 UTC
Signed with PGP, not checked
Commit: 8e7d20c17cb54209e4caf6e3e7fc2cf11f819d3f
Parent: 60f3a6c
4 files changed, +111 insertions, -7 deletions
@@ -42,19 +42,25 @@
42 42 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
43 43 }
44 44
45 - # Static error pages when app is down
45 + # Branded error pages. 404 and 500 come from the app: they are embedded in
46 + # the binary (src/routes/pages/public/error_pages.rs), so the copy and brand
47 + # glyphs cannot drift a release behind the site, and no per-deploy file
48 + # upload keeps them in sync.
49 + #
50 + # The catch-all stays a file read. It fires when the app is unreachable,
51 + # which is precisely when the app cannot render its own page, so 502.html
52 + # must exist on disk. Sando ships error-pages/ as a release sibling and
53 + # /opt/makenotwork/error-pages symlinks to current/error-pages.
46 54 handle_errors {
47 55 @404 expression {err.status_code} == 404
48 56 handle @404 {
49 - root * /opt/makenotwork/error-pages
50 - rewrite * /404.html
51 - file_server
57 + rewrite * /__errors/404.html
58 + reverse_proxy localhost:3000
52 59 }
53 60 @500 expression {err.status_code} == 500
54 61 handle @500 {
55 - root * /opt/makenotwork/error-pages
56 - rewrite * /500.html
57 - file_server
62 + rewrite * /__errors/500.html
63 + reverse_proxy localhost:3000
58 64 }
59 65 handle {
60 66 root * /opt/makenotwork/error-pages
@@ -52,6 +52,10 @@
52 52 || hit(path, "/rustdoc")
53 53 || path == "/favicon.ico"
54 54 || path == "/robots.txt"
55 + // Caddy proxies its own 404/500 to these. Gating them would answer a
56 + // 404 with a redirect to /login, so the gated site would have no error
57 + // page at all.
58 + || hit(path, "/__errors")
55 59 // Operational endpoints: the deploy smoke check and machine callers.
56 60 || path == "/health"
57 61 || path == "/metrics"
@@ -120,6 +124,7 @@
120 124 "/metrics",
121 125 "/stripe/webhook",
122 126 "/postmark/inbound",
127 + "/__errors/404.html",
123 128 ] {
124 129 // path_is_exempt sees the path only (no query), mirroring uri().path().
125 130 let path = p.split('?').next().unwrap();
@@ -3,6 +3,7 @@
3 3 pub(crate) mod content;
4 4 mod discover;
5 5 mod docs;
6 + mod error_pages;
6 7 mod feed;
7 8 mod health;
8 9 pub(crate) mod join_wizard;
@@ -86,6 +87,10 @@
86 87 "/api/health",
87 88 get(health::health_json).layer(GovernorLayer::new(search_rate_limit.clone())),
88 89 )
90 + // Caddy's `handle_errors` proxies its own 404/500 here so the branded
91 + // pages ship with the binary instead of as a per-deploy file upload.
92 + // 502 is not served here on purpose — see `error_pages`.
93 + .route_get("/__errors/{name}", get(error_pages::error_page))
89 94 .route_get("/robots.txt", get(sitemap::robots_txt))
90 95 .route_get("/sitemap.xml", get(sitemap::sitemap_xml))
91 96 // NOTE: GET /login is registered in auth_routes() alongside POST /login
@@ -1,0 +1,88 @@
1 + //! `/__errors/{status}.html` — the branded error pages, embedded in the binary.
2 + //!
3 + //! These three pages used to live only as files that `deploy.sh` scp'd to
4 + //! `/opt/makenotwork/error-pages/` on every deploy, and Caddy's `handle_errors`
5 + //! read them from disk. That coupled a per-deploy file upload to a binary that
6 + //! versions with the copy and the brand glyphs the pages carry: the pages could
7 + //! drift a release behind the site they front, and a forgotten `--config` run
8 + //! was the only thing standing between a rebrand and a stale 404.
9 + //!
10 + //! Embedding them makes the pages ride the binary, so they cannot be a release
11 + //! behind it. Caddy proxies its own 404/500 here (see `deploy/Caddyfile`).
12 + //!
13 + //! `502.html` stays on disk and is deliberately NOT reachable through this
14 + //! route: 502 is the app-is-down page, and a page the app has to serve is
15 + //! exactly the page that will not render when it is needed. Sando ships the
16 + //! directory as a release sibling (`release_contents` in the daemon config), so
17 + //! Caddy keeps a disk copy for that one case.
18 +
19 + use axum::{
20 + extract::Path,
21 + http::{StatusCode, header},
22 + response::{IntoResponse, Response},
23 + };
24 +
25 + /// Embedded relative to the crate root so the constant does not depend on the
26 + /// process working directory (same pattern as `deploy_lint::CADDYFILE`).
27 + const PAGE_404: &str = include_str!(concat!(
28 + env!("CARGO_MANIFEST_DIR"),
29 + "/deploy/error-pages/404.html"
30 + ));
31 + const PAGE_500: &str = include_str!(concat!(
32 + env!("CARGO_MANIFEST_DIR"),
33 + "/deploy/error-pages/500.html"
34 + ));
35 +
36 + /// Serve an embedded error page under its own status code.
37 + ///
38 + /// The status is echoed rather than 200 because Caddy's `handle_errors` block
39 + /// forwards the upstream status to the client; answering 200 would turn every
40 + /// Caddy-generated 404 into a soft-404 for crawlers.
41 + ///
42 + /// An unknown name is itself a 404 with the 404 page, which is the only
43 + /// coherent answer: there is nothing else to say about `/__errors/tea.html`.
44 + pub(super) async fn error_page(Path(name): Path<String>) -> Response {
45 + let (status, body) = match name.as_str() {
46 + "500.html" => (StatusCode::INTERNAL_SERVER_ERROR, PAGE_500),
47 + _ => (StatusCode::NOT_FOUND, PAGE_404),
48 + };
49 + (
50 + status,
51 + [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
52 + body,
53 + )
54 + .into_response()
55 + }
56 +
57 + #[cfg(test)]
58 + mod tests {
59 + use super::*;
60 +
61 + /// The embed is only useful if it actually caught the real pages. A path
62 + /// typo would compile-fail, but an empty or truncated file would not.
63 + #[test]
64 + fn embedded_pages_are_the_real_pages() {
65 + for page in [PAGE_404, PAGE_500] {
66 + assert!(page.starts_with("<!DOCTYPE html>"), "not an HTML document");
67 + assert!(page.contains("makenot.work"), "missing the wordmark");
68 + }
69 + assert!(PAGE_404.contains("404"));
70 + assert!(PAGE_500.contains("500"));
71 + }
72 +
73 + /// 502 must not be reachable here — see the module docs. This is the test
74 + /// that fails if someone "completes the set" later.
75 + #[tokio::test]
76 + async fn unknown_names_including_502_serve_the_404_page() {
77 + for name in ["502.html", "tea.html", "../../etc/passwd"] {
78 + let res = error_page(Path(name.to_string())).await;
79 + assert_eq!(res.status(), StatusCode::NOT_FOUND, "{name}");
80 + }
81 + }
82 +
83 + #[tokio::test]
84 + async fn status_matches_the_page() {
85 + let res = error_page(Path("500.html".to_string())).await;
86 + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
87 + }
88 + }