//! Makenotwork library, shared between the binary and integration tests. //! //! # Design //! //! Design plans, deploy runbooks, and the business/strategy/SOP docs live in the //! maintainer wiki; audit and fuzz findings are tracked as problems in GoingsOn. //! /// Tracing filter used when `RUST_LOG` is unset, which is how production runs. /// /// `csp_violation` is listed explicitly because it is its own target rather /// than a module under the crate path, so `makenotwork=...` does not reach it. /// It lives here rather than in `main.rs` so a test can assert an event on that /// target actually survives the filter; the endpoint answers 204 either way, so /// nothing else would notice the reports being dropped. pub const DEFAULT_LOG_FILTER: &str = "makenotwork=debug,tower_http=debug,sqlx=info,csp_violation=warn"; pub mod access_gate; pub mod auth; pub mod background; pub mod build_runner; pub mod changelog; pub mod cloudflare; pub mod config; pub mod constants; pub mod crypto; pub mod csrf; pub mod currency; /// Custom Pages sanitization, re-exported from the `custom-pages` crate. /// /// The crate lives outside the server so its fuzz targets do not need a server /// build. The re-export keeps `crate::custom_pages::` resolving for the call /// sites and doc links that name it. pub use custom_pages; pub mod db; pub mod email; pub mod error; pub mod extractors; pub mod fee_calculator; pub mod formatting; pub mod fragment_redirect; pub mod git; pub mod git_ssh; pub mod helpers; pub mod import; pub mod license_templates; pub mod markdown; pub mod metrics; pub mod monitor; pub mod mt_client; pub mod oauth_scope; pub mod openapi; pub mod payments; pub mod pricing; pub mod quasi; pub mod quasi_spike; pub mod rate_limit; pub mod routes; pub mod rss; pub mod scanning; pub mod scheduler; pub mod security_signals; pub mod seed; pub mod shell; pub mod site_docs; pub mod storage; pub mod synckit_auth; pub mod synckit_billing; pub mod templates; pub mod theming; pub mod tier_prices; pub mod types; pub mod validation; pub mod wam_client; pub mod wordlist; // Test-only lint: enforce that every Caddy site block proxying the app declares // a safe IP-trust posture. Compiled only under `cargo test`. #[cfg(test)] mod deploy_lint; // Test-only tracing capture. Shared, because callsite interest is a global // cache and a per-test subscriber makes it depend on test order. #[cfg(test)] mod test_tracing; use axum::{Router, extract::FromRef, http::HeaderValue, middleware}; use std::time::Instant; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; use tower_http::set_header::SetResponseHeaderLayer; use tower_sessions::SessionManagerLayer; use tower_sessions_sqlx_store::PostgresStore; use std::sync::Arc; use dashmap::DashMap; use db::{SyncAppId, UserId, UserSessionId}; use config::Config; use docengine::DocLoader; use email::EmailClient; use payments::{PaymentCapabilities, PaymentProvider}; use routes::{ admin_routes, api_routes, auth_routes, build_routes, git_issue_routes, git_routes, git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, rpm_routes, sso_routes, storage_routes, stripe_routes, synckit_routes, }; use scanning::ScanPipeline; use storage::StorageBackend; use webauthn_rs::Webauthn; /// Application state shared across all handlers. /// /// `#[derive(FromRef)]` generates `FromRef` for every field type, so a /// handler can extract just the slice it needs, `State`, `State`, /// `State`, `State`, etc., instead of the whole struct. /// This narrows each handler's declared dependencies without changing the runtime /// (the state is still one `Clone`-cheap value shared by every task). See /// `_private/docs/mnw/appstate-decomposition-plan.md`. #[derive(Clone, FromRef)] pub struct AppState { pub db: sqlx::PgPool, pub config: Config, /// S3-compatible storage backends: main private bucket, SyncKit blob /// bucket, and the public CDN-served bucket. pub storage: AppStorage, pub payments: Option>, /// Typed handles to the optional capabilities the wired provider also /// implements (hosted portals, Connect onboarding, refunds, and the rest). /// Populated from the same value as `payments`; see /// [`payments::PaymentCapabilities`]. pub payment_caps: PaymentCapabilities, pub email: EmailClient, pub docs: Arc, pub tier_prices: tier_prices::TierPrices, pub runway_config: tier_prices::RunwayConfig, /// Public `/pricing` fee calculator (Stripe's fees + the opening /// position of each dial), parsed from `assumptions.toml` at startup. pub fee_calculator: fee_calculator::FeeCalculator, pub scanner: Option>, pub webauthn: Arc, pub syntax: Option>, // Plain timestamp types: skip FromRef so no handler extracts a bare // `State>` / `State` and to keep those types free for // an `Ops` slice in Phase 1. #[from_ref(skip)] pub started_at: chrono::DateTime, #[from_ref(skip)] pub start_instant: Instant, /// HTTP client for the Multithreaded internal API (community/thread provisioning). pub mt_client: Option, /// HTTP client for the WAM ticket manager (operational alerts). pub wam: Option, /// Derived in-memory caches (sessions, custom domains, SyncKit SSE fan-out). pub caches: AppCaches, /// Concurrency limiters for memory-/process-heavy request paths. pub limiters: AppLimiters, /// Unix timestamp when the server will restart (0 = no restart pending). /// Set by the deploy script via the internal API before uploading a new binary. pub restart_at: Arc, /// Prometheus metrics handle for rendering the admin dashboard. `None` in tests. pub metrics_handle: Option, /// Bounded batcher for page-view UPSERTs. Replaces the previous /// `tokio::spawn(record_view(...))` per request which under burst saturated /// the DB pool. `try_record` is non-blocking; drops on overflow. pub page_view_tx: db::page_views::PageViewTx, /// Bounded background-task queue for fire-and-forget work (email sends, /// mailing-list subscriptions, etc.). Replaces per-request `tokio::spawn` /// for low-priority work; bounded queue + bounded concurrent execution /// prevent burst traffic from starving the DB pool. See `background.rs`. pub bg: background::BackgroundTx, } /// S3-compatible storage backends held by [`AppState`]. #[derive(Clone)] pub struct AppStorage { /// Main private bucket (uploaded originals, staged content). pub s3: Option>, /// Separate bucket for SyncKit blob storage. pub synckit_s3: Option>, /// Public, CDN-served bucket. Holds only promoted image content (covers, /// gallery, item/project images); the scan worker copies Clean image /// objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset. pub public_s3: Option>, /// Bucket holding the Alloy hotfix RPM repository. Written only by the /// admin publish endpoint (`routes::rpm`); read by nobody here, because the /// repo is fetched straight off the bucket through Caddy. `None` when the /// bucket is unconfigured, which is every dev environment. pub rpm_s3: Option>, } /// Derived in-memory caches held by [`AppState`]. All are `Arc` so a /// clone of `AppState` shares one map across every handler task. #[derive(Clone)] pub struct AppCaches { /// Recently-validated session tracking IDs → last-validated instant, to /// skip a per-request DB touch. Entries older than SESSION_TOUCH_CACHE_SECS /// are treated as expired. pub session_cache: Arc>, /// Verified custom domains → user IDs (populated on startup, updated on /// verify/delete). No TTL; invalidated explicitly on user deletion. pub domain_cache: Arc>, /// SyncKit SSE push channels. Key: (app_id, user_id); value: broadcast /// sender carrying the new max `seq` after each push so a subscriber already /// at that cursor can skip a redundant pull. pub sync_notify: Arc>>, /// Concurrent SSE connection count per user (for rate limiting). pub sse_connections: Arc>, /// Flattened `refs/notes/*` trees, so a log page does not re-walk the notes /// tree on every request. Keyed by repo path + namespace + ref tip, which /// means a moved ref is a miss rather than a stale hit and there is no /// invalidation to get wrong. pub notes_cache: Arc, } /// Concurrency limiters held by [`AppState`], guarding memory-/process-heavy /// request paths. #[derive(Clone)] pub struct AppLimiters { /// Limits concurrent file scans to prevent memory exhaustion (each scan /// downloads up to SCAN_MAX_MEMORY_BYTES into RAM). pub scan_semaphore: Arc, /// Caps concurrent cache-miss DB lookups in `caddy-ask` so a flood of /// unknown-domain queries can't saturate the pool or drive ACME issuance. pub caddy_ask_semaphore: Arc, /// Caps concurrent git smart-HTTP clone/fetch responses. Each `git /// upload-pack` spawns a child process and streams a packfile that can be /// arbitrarily large for a big repo; without a permit, N concurrent clones /// fan out unbounded processes + memory. The packfile body is streamed (not /// buffered), so this bounds the process count, not a heap ceiling. pub git_smart_http_semaphore: Arc, } // Capability slices (decomposition Phase 1). // // Each of these is a logical grouping of `AppState` fields that a handler can // extract as `State` instead of the whole god struct. They are projection // *views*, not physical containers: the fields keep their existing homes on // `AppState` (flat, or inside `AppStorage`/`AppCaches`/`AppLimiters`), and a // hand-written `FromRef` clones the relevant ones on extraction. This // is what lets a slice like `Sync` or `Scanning` draw from fields that live in // different physical sub-structs. Every field here is `Arc`/pool/small-`Clone`, // so the projection clone is cheap. Single-field slices (e.g. `State`, // `State`, `State`) come free from the `AppState` // derive and don't need a struct here. // // See `_private/docs/mnw/appstate-decomposition-plan.md`. /// Billing/pricing slice: the Stripe provider plus the pricing tables derived /// from `assumptions.toml`. Used by checkout, subscription, promo, and the /// public `/pricing` page. #[derive(Clone)] pub struct Billing { pub payments: Option>, pub payment_caps: PaymentCapabilities, pub tier_prices: tier_prices::TierPrices, pub runway_config: tier_prices::RunwayConfig, pub fee_calculator: fee_calculator::FeeCalculator, } impl FromRef for Billing { fn from_ref(s: &AppState) -> Self { Self { payments: s.payments.clone(), payment_caps: s.payment_caps.clone(), tier_prices: s.tier_prices.clone(), runway_config: s.runway_config.clone(), fee_calculator: s.fee_calculator.clone(), } } } /// External-service integration clients (both optional, tailnet/internal). #[derive(Clone)] pub struct Integrations { pub mt_client: Option, pub wam: Option, } impl FromRef for Integrations { fn from_ref(s: &AppState) -> Self { Self { mt_client: s.mt_client.clone(), wam: s.wam.clone(), } } } /// File-scanning slice: the scan pipeline that handlers enqueue work onto. /// /// Just `scanner`. The `scan_semaphore` that bounds concurrent in-memory scans /// is *not* here: it is consumed only inside the scan worker /// ([`scanning::worker::WorkerContext`], wired directly from [`AppLimiters`] at /// startup), never by a request handler. Every handler that extracts /// `State` reads only `scanner`, so bundling the worker-only semaphore /// in would be dead weight that misstates the handler's dependencies. #[derive(Clone)] pub struct Scanning { pub scanner: Option>, } impl FromRef for Scanning { fn from_ref(s: &AppState) -> Self { Self { scanner: s.scanner.clone(), } } } /// SyncKit slice: the blob bucket plus the SSE push/rate-limit maps. Draws /// `synckit_s3` from [`AppStorage`] and the two maps from [`AppCaches`]. #[derive(Clone)] pub struct Sync { pub synckit_s3: Option>, pub sync_notify: Arc>>, pub sse_connections: Arc>, } impl FromRef for Sync { fn from_ref(s: &AppState) -> Self { Self { synckit_s3: s.storage.synckit_s3.clone(), sync_notify: s.caches.sync_notify.clone(), sse_connections: s.caches.sse_connections.clone(), } } } /// Depth of each per-(app,user) SSE broadcast channel. A subscriber that lags /// past this many un-received `seq` values gets a `Lagged` error and skips to /// the latest (the client pulls anyway), so the buffer only needs to cover a /// brief scheduling gap between a push and the subscriber's poll. const SYNC_NOTIFY_CHANNEL_DEPTH: usize = 16; impl Sync { /// Notify SSE subscribers of a push to `(app_id, user_id)`, carrying the new /// max `seq` so a device already at (or past) this cursor can skip a /// redundant pull. No-op when nobody is subscribed. pub fn notify_push(&self, app_id: SyncAppId, user_id: UserId, cursor: i64) { if let Some(sender) = self.sync_notify.get(&(app_id, user_id)) { let _ = sender.send(cursor); // Err = no subscribers, which is fine. } } /// Get-or-create the broadcast channel for `(app_id, user_id)` and return a /// fresh receiver. The channel is created lazily on first subscribe; the /// SSE connection guard prunes it once the last receiver drops. pub fn subscribe_channel( &self, app_id: SyncAppId, user_id: UserId, ) -> tokio::sync::broadcast::Receiver { self.sync_notify .entry((app_id, user_id)) .or_insert_with(|| tokio::sync::broadcast::channel(SYNC_NOTIFY_CHANNEL_DEPTH).0) .value() .subscribe() } } /// Git source-browser slice: the syntax highlighter (blob rendering) plus the /// semaphore that bounds concurrent smart-HTTP clone/fetch child processes. /// Draws `syntax` from the top level and `git_smart_http_semaphore` from /// [`AppLimiters`]. #[derive(Clone)] pub struct Git { pub syntax: Option>, pub smart_http_semaphore: Arc, pub notes_cache: Arc, } impl FromRef for Git { fn from_ref(s: &AppState) -> Self { Self { syntax: s.syntax.clone(), smart_http_semaphore: s.limiters.git_smart_http_semaphore.clone(), notes_cache: Arc::clone(&s.caches.notes_cache), } } } /// Observability/lifecycle slice: uptime clocks, the pending-restart flag, and /// the Prometheus render handle. Used by status/health/admin/metrics handlers. /// (`started_at`/`start_instant` are `#[from_ref(skip)]` on `AppState`, so this /// view is the only way to extract them without the whole struct.) #[derive(Clone)] pub struct Ops { pub started_at: chrono::DateTime, pub start_instant: Instant, pub restart_at: Arc, pub metrics_handle: Option, } impl FromRef for Ops { fn from_ref(s: &AppState) -> Self { Self { started_at: s.started_at, start_instant: s.start_instant, restart_at: s.restart_at.clone(), metrics_handle: s.metrics_handle.clone(), } } } /// Externally-wired dependencies handed to [`AppState::build`]. /// /// Holds every field the caller must construct from the environment (pools, /// clients, loaded config, the warmed domain cache). [`AppState::build`] assembles /// these into an [`AppState`] and fills in the purely-derived in-memory state /// (start timestamps, the empty cache maps, the concurrency semaphores) itself, so /// those defaults live in one place instead of being spelled out at each call site. pub struct AppStateParts { pub db: sqlx::PgPool, pub config: Config, pub storage: AppStorage, pub payments: Option>, pub payment_caps: PaymentCapabilities, pub email: EmailClient, pub docs: Arc, pub tier_prices: tier_prices::TierPrices, pub runway_config: tier_prices::RunwayConfig, pub fee_calculator: fee_calculator::FeeCalculator, pub scanner: Option>, pub webauthn: Arc, pub syntax: Option>, pub mt_client: Option, pub wam: Option, /// Custom-domain cache, already warmed from the DB by the caller. pub domain_cache: Arc>, pub metrics_handle: Option, pub page_view_tx: db::page_views::PageViewTx, pub bg: background::BackgroundTx, } impl AppStorage { /// Get the main S3 storage backend, or error if not configured. pub fn require_s3(&self) -> error::Result<&Arc> { self.s3.as_ref().ok_or_else(|| { error::AppError::ServiceUnavailable("File storage is not configured".to_string()) }) } /// Get the SyncKit S3 storage backend, or error if not configured. pub fn require_synckit_s3(&self) -> error::Result<&Arc> { self.synckit_s3.as_ref().ok_or_else(|| { error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string()) }) } /// Get the public (CDN-served) S3 storage backend, or error if not configured. pub fn require_public_s3(&self) -> error::Result<&Arc> { self.public_s3.as_ref().ok_or_else(|| { error::AppError::ServiceUnavailable( "Public storage bucket is not configured".to_string(), ) }) } /// Get the RPM-repo S3 storage backend, or error if not configured. pub fn require_rpm_s3(&self) -> error::Result<&Arc> { self.rpm_s3.as_ref().ok_or_else(|| { error::AppError::ServiceUnavailable("RPM bucket is not configured".to_string()) }) } } impl AppState { /// Assemble an [`AppState`] from its externally-wired dependencies. /// /// The caller builds the pieces that depend on the environment (DB pool, S3 /// clients, Stripe, loaded docs, derived pricing, the warmed domain cache) and /// passes them in via [`AppStateParts`]; this fills in the purely-derived /// in-memory state (start timestamps, empty session/SSE caches, concurrency /// semaphores sized from [`constants`]). It has no process-global side effects: /// `TierPrices::install_global` is an explicit step at the call site, kept out /// of construction so the ordering stays visible. pub fn build(parts: AppStateParts) -> Self { Self { db: parts.db, config: parts.config, storage: parts.storage, payments: parts.payments, payment_caps: parts.payment_caps, email: parts.email, docs: parts.docs, tier_prices: parts.tier_prices, runway_config: parts.runway_config, fee_calculator: parts.fee_calculator, scanner: parts.scanner, webauthn: parts.webauthn, syntax: parts.syntax, started_at: chrono::Utc::now(), start_instant: Instant::now(), mt_client: parts.mt_client, wam: parts.wam, restart_at: Arc::new(std::sync::atomic::AtomicI64::new(0)), metrics_handle: parts.metrics_handle, page_view_tx: parts.page_view_tx, bg: parts.bg, caches: AppCaches { session_cache: Arc::new(DashMap::new()), domain_cache: parts.domain_cache, sync_notify: Arc::new(DashMap::new()), sse_connections: Arc::new(DashMap::new()), notes_cache: Arc::new(git::notes::NotesCache::default()), }, limiters: AppLimiters { scan_semaphore: Arc::new(tokio::sync::Semaphore::new( constants::SCAN_MAX_CONCURRENT, )), caddy_ask_semaphore: Arc::new(tokio::sync::Semaphore::new( constants::CADDY_ASK_MAX_CONCURRENT, )), git_smart_http_semaphore: Arc::new(tokio::sync::Semaphore::new( constants::GIT_SMART_HTTP_MAX_CONCURRENT, )), }, } } /// Get the main S3 storage backend, or error if not configured. pub fn require_s3(&self) -> error::Result<&Arc> { self.storage.require_s3() } /// Get the SyncKit S3 storage backend, or error if not configured. pub fn require_synckit_s3(&self) -> error::Result<&Arc> { self.storage.require_synckit_s3() } /// Get the public (CDN-served) S3 storage backend, or error if not configured. pub fn require_public_s3(&self) -> error::Result<&Arc> { self.storage.require_public_s3() } } /// Delete a user account and purge every derived in-memory cache keyed to it. /// /// This is the single deletion entry point for handlers and the scheduler. The /// raw `db::users::delete_user` is `pub(crate)` so a caller cannot delete a user /// and forget to purge `domain_cache`, which has no TTL and never re-validates a /// hit, so a stale entry would otherwise linger until process restart. The custom /// domain rows cascade at the DB layer; here we drop the matching cache entries. /// /// Takes the `Db` and `AppCaches` slices rather than the whole `AppState` so a /// caller can hold just those (the decomposition seam); the coupling of the /// mutation to the invalidation is preserved by requiring both. pub async fn delete_user_account( db: &sqlx::PgPool, caches: &AppCaches, user_id: db::UserId, ) -> error::Result<()> { db::users::delete_user(db, user_id).await?; caches.domain_cache.retain(|_, uid| *uid != user_id); Ok(()) } /// Build the app router with all routes and middleware (minus tracing/TCP). pub fn build_app(state: &AppState, session_layer: SessionManagerLayer) -> Router { // All mutation-bearing sub-routers register through `CsrfRouter`, whose // `route` method only accepts `PostureMethodRouter` values produced by // the `csrf::*_csrf*` helpers. Finalising the merged tree drops the // structural envelope so global middleware, static-file mounts, and // the few bare GETs below can attach to a plain `Router`. let csrf_routes = csrf::CsrfRouter::new() .merge(auth_routes(state.config.rate_limits)) .merge(api_routes(state.config.rate_limits)) .merge(storage_routes()) .merge(stripe_routes()) .merge(admin_routes(state.clone())) .merge(synckit_routes( state .config .synckit_jwt_secret .clone() .map(std::sync::Arc::new), )) .merge(oauth_routes()) .merge(postmark_routes()) .merge(git_issue_routes()) .merge(git_write_routes()) .merge(ota_routes()) .merge(rpm_routes()) .merge(build_routes()); // The description layer, when a screen is switched on. Inside the CSRF tree // rather than beside it, so a described write is covered by the same // envelope every other mutation is: `origin_gate` from `finalize` below, // and the Auto token check from `nest_service`. It sat outside until // 2026-08-11, which was harmless only for as long as described screens // served nothing but GET. // // Nested as a service because the adapter routes internally and carries its // own state, resolved per request; it takes none from axum. The Askama route // for a described screen is not registered (see `dashboard_routes` and // `public_routes`), so nothing here overlaps. let csrf_routes = quasi::mounts(state) .into_iter() .fold(csrf_routes, |routes, (path, described)| { routes.nest_service(path, described) }); // The screens that own their whole document. Same envelope and the same // factory; what differs is a session gate in front, so an address a person // can type answers the branded 401 rather than the adapter's bare 403. // See `quasi::document_mounts`. let csrf_routes = quasi::document_mounts(state) .into_iter() .fold(csrf_routes, |routes, (path, described)| { routes.route_service(path, described) }); // The public documents: an exact address like the gated ones, and no // session gate in front, so a visitor is rendered to rather than refused. // See `quasi::public_document_mounts`. let csrf_routes = quasi::public_document_mounts(state) .into_iter() .fold(csrf_routes, |routes, (path, described)| { routes.route_service(path, described) }); // The public half of the description layer. Same envelope, different // factory: these screens resolve no session. See `quasi::public_mounts`. let csrf_routes = quasi::public_mounts(state) .into_iter() .fold(csrf_routes, |routes, (path, described)| { routes.nest_service(path, described) }); let csrf_routes = csrf_routes.finalize(); let app = Router::new() .merge(page_routes(state.config.rate_limits)) .merge(sso_routes()) .merge(csrf_routes) .merge(git_routes()) .merge(routes::embed::embed_routes()) // Tier G1. Serves /spike/docs beside /docs so the two can be diffed. // Mounted as a service, not merged: the adapter mounts as a fallback and // this server has one. Delete with the module when G2 is answered. .nest_service("/spike", quasi_spike::router(Arc::clone(&state.docs))) .route( "/api/openapi.json", axum::routing::get(openapi::openapi_json), ) .merge(utoipa_swagger_ui::SwaggerUi::new("/api/docs").url( "/api-docs/openapi.json", ::openapi(), )) // The ES module graph, served under a fingerprinted DIRECTORY. // // Every other static asset is referenced from a template as // `path?v=`, so a week-long cache is safe: a deploy changes the // URL. A module graph is the exception, and it took the whole site's // JavaScript down on 2026-08-14. Only the ENTRY point is named by a // template; the entry's own `import './dispatch.js'` is a bare relative // URL that a deploy never changes. Cloudflare went on serving a // week-old `dispatch.js` beside a fresh `index.js`, the two disagreed // about an export name, and the graph failed to link with a // SyntaxError. That takes down every island on the page, because // `core/index.ts` side-effect-imports all of them. // // Putting the hash in the directory rather than in a query is what // fixes it, and it fixes it for imports nobody has written yet: a // relative import resolves against the document URL, so // `/static/dist-ab12/core/index.js` asking for `./dispatch.js` gets // `/static/dist-ab12/core/dispatch.js` for free. Every member of the // graph moves together, by construction, and members from two different // deploys can never meet. // // The long cache comes back with it, and is now honest: these URLs are // immutable, because the next deploy has a different directory. // // `?v=` was tried first and is not enough. A middle attempt set // `no-cache` on `/static/dist`, which is correct but gives up edge // caching on the JS and, more to the point, does nothing about an // object the CDN already holds under the old policy. .nest_service( concat!("/static/dist-", env!("STATIC_VERSION")), tower::ServiceBuilder::new() .layer(SetResponseHeaderLayer::overriding( axum::http::header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=604800, immutable"), )) .service(ServeDir::new("static/dist")), ) // The unversioned path stays, and revalidates. Nothing this build emits // points at it; it is here for a page a browser is still holding from // before the versioned directory existed, whose cached entry document // still names `/static/dist/...`. Remove it once no such document can // be in flight. .nest_service( "/static/dist", tower::ServiceBuilder::new() .layer(SetResponseHeaderLayer::overriding( axum::http::header::CACHE_CONTROL, HeaderValue::from_static("public, no-cache"), )) .service(ServeDir::new("static/dist")), ) // `static/bases/` rides on this mount: the content-addressed mirror of // the font files `quasi-type` pins, fetched by an Alloy image build so // it does not depend on somebody else's rate limiter. It has no route, // so nothing here would notice a renamed or corrupted file; // `tests/bases_mirror.rs` is what checks the names still equal the // digests. .nest_service( "/static", tower::ServiceBuilder::new() .layer(SetResponseHeaderLayer::overriding( axum::http::header::CACHE_CONTROL, HeaderValue::from_static( "public, max-age=604800, stale-while-revalidate=86400", ), )) .service(ServeDir::new("static")), ) // Last, so it reaches every route registered above: a method mismatch // on a path that does exist renders the branded error page rather than // axum's bodiless 405, which browsers replace with their own // network-error screen. It rewrites the method routers in place, so // anything added after this line would not get it. .method_not_allowed_fallback(error::method_not_allowed) .fallback(routes::custom_domain::custom_domain_fallback) .with_state(state.clone()); // There is no /metrics scrape endpoint. Prometheus and Grafana were retired // on 2026-07-21 and PoM is the monitoring story, so the endpoint had no // consumer left. The recorder itself stays: the admin metrics dashboard // renders from the same handle in-process. // All rate limiters were registered as they were built above; start the // periodic GC that sweeps their bucket maps so they don't grow unbounded for // process lifetime (Run #14 CHRONIC 1). Guarded by `Once` internally. crate::rate_limit::start_governor_sweeper(); // Innermost of this chain, so it runs closest to the routes: a direct // navigation to a fragment endpoint is redirected after the session and // access-gate layers have done their work, not instead of them. app.layer(middleware::from_fn( fragment_redirect::fragment_redirect_middleware, )) .layer(middleware::from_fn(request_timeout_middleware)) .layer(middleware::from_fn_with_state( state.clone(), access_gate::access_gate_middleware, )) .layer(middleware::from_fn_with_state( state.clone(), security_headers_middleware, )) .layer(middleware::from_fn(metrics::cache_control_middleware)) .layer(middleware::from_fn(metrics::metrics_middleware)) .layer(middleware::from_fn_with_state( state.clone(), metrics::idempotency_middleware, )) .layer(session_layer) .layer(RequestBodyLimitLayer::new(1024 * 1024)) // Outermost: requests to the user-pages host (`u.makenot.work`) are // served custom pages here and short-circuit before the session and // access-gate layers, so that host stays cookieless and ungated. // Everything else (and `/static`) falls through to the normal app. .layer(middleware::from_fn_with_state( state.clone(), routes::user_pages::dispatch, )) } /// Wall-clock ceiling on response generation. Generous, every normal page/API /// handler finishes in well under this; the bound exists to catch a handler that /// wedges on a stuck upstream (S3 GET, a hung DB call with no per-query timeout) /// rather than to pace healthy traffic. const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2); /// Routes exempt from [`REQUEST_TIMEOUT`]: ones that legitimately run long or /// stream open-ended bodies. git smart-HTTP (clone/pack), data exports, the /// SyncKit SSE push channel, and OTA artifact transfer. /// /// Anchored to real route prefixes, not substrings: the old /// `contains("/export")` also exempted the `/dashboard/export` page and any /// future creator-controlled path containing the token, silently widening the /// un-timed-out surface. The `/sync/builds` substring matched no route (build /// artifacts move over the OTA path, already covered). fn timeout_exempt(path: &str) -> bool { path.starts_with("/git") || path.starts_with("/api/export/") || path.starts_with("/api/internal/creator/export/") || path.starts_with("/api/sync/subscribe") || path.starts_with("/api/v1/sync/subscribe") || path.starts_with("/api/sync/ota") || path.starts_with("/api/v1/sync/ota") } /// Global request timeout with per-route opt-out. A single hung handler used to /// pin a connection (and its DB conn / scan permit) indefinitely; this bounds /// every non-exempt handler to [`REQUEST_TIMEOUT`] and returns 504 if it blows /// past it. Streaming-body handlers are unaffected anyway (the body flows after /// this future returns), but they are listed in [`timeout_exempt`] for clarity. async fn request_timeout_middleware( request: axum::http::Request, next: middleware::Next, ) -> axum::response::Response { use axum::response::IntoResponse; if timeout_exempt(request.uri().path()) { return next.run(request).await; } match tokio::time::timeout(REQUEST_TIMEOUT, next.run(request)).await { Ok(response) => response, Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Request timed out").into_response(), } } /// Middleware that sets security headers on all responses. /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding. async fn security_headers_middleware( axum::extract::State(state): axum::extract::State, request: axum::http::Request, next: middleware::Next, ) -> axum::response::Response { let path = request.uri().path(); let is_embed = path.starts_with("/embed/"); // OAuth PKCE consent posts redirect to the RP's registered redirect_uri, // which for native/desktop clients is a loopback callback per RFC 8252 §7.3. // form-action applies to redirect chains (CSP3 §5.1), so a bare 'self' blocks // the callback and the user sees the login form re-post with no visible // effect. Widen form-action on the authorize endpoints only. let is_oauth_authorize = path == "/oauth/authorize"; let mut response = next.run(request).await; let headers = response.headers_mut(); // Violation reporting. `report-to` is the current mechanism and `report-uri` // the deprecated one, and both are sent because no browser supports both: // Safari still only reads report-uri. The endpoint is absolute so the // Reporting-Endpoints value is a valid URL on every parser. let report_endpoint = format!( "{}/api/csp-report", state.config.host_url.trim_end_matches('/') ); let reporting = format!("; report-uri {report_endpoint}; report-to mnw-csp"); if let Ok(value) = HeaderValue::from_str(&format!("mnw-csp=\"{report_endpoint}\"")) { headers.insert( axum::http::header::HeaderName::from_static("reporting-endpoints"), value, ); } if is_embed { // Embed routes: framable from any origin, but otherwise locked down. // `frame-ancestors *` alone (the old value) left default-src/script-src // unrestricted, so an embed XSS would have had no CSP backstop. We keep // inline script/style (the audio-player embed uses an inline