Skip to main content

max / makenotwork

41.4 KB · 969 lines History Blame Raw
1 //! Makenotwork library, shared between the binary and integration tests.
2 //!
3 //! # Design
4 //!
5 //! Design plans, deploy runbooks, and the business/strategy/SOP docs live in the
6 //! maintainer wiki; audit and fuzz findings are tracked as problems in GoingsOn.
7 //! <!-- wiki: mnw-server-overview -->
8
9 /// Tracing filter used when `RUST_LOG` is unset, which is how production runs.
10 ///
11 /// `csp_violation` is listed explicitly because it is its own target rather
12 /// than a module under the crate path, so `makenotwork=...` does not reach it.
13 /// It lives here rather than in `main.rs` so a test can assert an event on that
14 /// target actually survives the filter; the endpoint answers 204 either way, so
15 /// nothing else would notice the reports being dropped.
16 pub const DEFAULT_LOG_FILTER: &str =
17 "makenotwork=debug,tower_http=debug,sqlx=info,csp_violation=warn";
18
19 pub mod access_gate;
20 pub mod auth;
21 pub mod background;
22 pub mod build_runner;
23 pub mod changelog;
24 pub mod cloudflare;
25 pub mod config;
26 pub mod constants;
27 pub mod crypto;
28 pub mod csrf;
29 pub mod currency;
30 pub mod custom_pages;
31 pub mod db;
32 pub mod email;
33 pub mod error;
34 pub mod extractors;
35 pub mod fee_calculator;
36 pub mod formatting;
37 pub mod fragment_redirect;
38 pub mod git;
39 pub mod git_ssh;
40 pub mod helpers;
41 pub mod import;
42 pub mod license_templates;
43 pub mod markdown;
44 pub mod metrics;
45 pub mod monitor;
46 pub mod mt_client;
47 pub mod oauth_scope;
48 pub mod openapi;
49 pub mod payments;
50 pub mod pricing;
51 pub mod quasi;
52 pub mod quasi_spike;
53 pub mod rate_limit;
54 pub mod routes;
55 pub mod rss;
56 pub mod scanning;
57 pub mod scheduler;
58 pub mod security_signals;
59 pub mod seed;
60 pub mod shell;
61 pub mod site_docs;
62 pub mod storage;
63 pub mod synckit_auth;
64 pub mod synckit_billing;
65 pub mod templates;
66 pub mod theming;
67 pub mod tier_prices;
68 pub mod types;
69 pub mod validation;
70 pub mod wam_client;
71 pub mod wordlist;
72
73 // Test-only lint: enforce that every Caddy site block proxying the app declares
74 // a safe IP-trust posture. Compiled only under `cargo test`.
75 #[cfg(test)]
76 mod deploy_lint;
77
78 use axum::{Router, extract::FromRef, http::HeaderValue, middleware};
79 use std::time::Instant;
80 use tower_http::limit::RequestBodyLimitLayer;
81 use tower_http::services::ServeDir;
82 use tower_http::set_header::SetResponseHeaderLayer;
83 use tower_sessions::SessionManagerLayer;
84 use tower_sessions_sqlx_store::PostgresStore;
85
86 use std::sync::Arc;
87
88 use dashmap::DashMap;
89 use db::{SyncAppId, UserId, UserSessionId};
90
91 use config::Config;
92 use docengine::DocLoader;
93 use email::EmailClient;
94 use payments::PaymentProvider;
95 use routes::{
96 admin_routes, api_routes, auth_routes, build_routes, git_issue_routes, git_routes,
97 git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, sso_routes,
98 storage_routes, stripe_routes, synckit_routes,
99 };
100 use scanning::ScanPipeline;
101 use storage::StorageBackend;
102 use webauthn_rs::Webauthn;
103
104 /// Application state shared across all handlers.
105 ///
106 /// `#[derive(FromRef)]` generates `FromRef<AppState>` for every field type, so a
107 /// handler can extract just the slice it needs, `State<PgPool>`, `State<Config>`,
108 /// `State<AppStorage>`, `State<EmailClient>`, etc., instead of the whole struct.
109 /// This narrows each handler's declared dependencies without changing the runtime
110 /// (the state is still one `Clone`-cheap value shared by every task). See
111 /// `_private/docs/mnw/appstate-decomposition-plan.md`.
112 #[derive(Clone, FromRef)]
113 pub struct AppState {
114 pub db: sqlx::PgPool,
115 pub config: Config,
116 /// S3-compatible storage backends: main private bucket, SyncKit blob
117 /// bucket, and the public CDN-served bucket.
118 pub storage: AppStorage,
119 pub stripe: Option<Arc<dyn PaymentProvider>>,
120 pub email: EmailClient,
121 pub docs: Arc<DocLoader>,
122 pub tier_prices: tier_prices::TierPrices,
123 pub runway_config: tier_prices::RunwayConfig,
124 /// Public `/pricing` fee calculator (Stripe's fees + the opening
125 /// position of each dial), parsed from `assumptions.toml` at startup.
126 pub fee_calculator: fee_calculator::FeeCalculator,
127 pub scanner: Option<Arc<ScanPipeline>>,
128 pub webauthn: Arc<Webauthn>,
129 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
130 // Plain timestamp types: skip FromRef so no handler extracts a bare
131 // `State<DateTime<Utc>>` / `State<Instant>` and to keep those types free for
132 // an `Ops` slice in Phase 1.
133 #[from_ref(skip)]
134 pub started_at: chrono::DateTime<chrono::Utc>,
135 #[from_ref(skip)]
136 pub start_instant: Instant,
137 /// HTTP client for the Multithreaded internal API (community/thread provisioning).
138 pub mt_client: Option<mt_client::MtClient>,
139 /// HTTP client for the WAM ticket manager (operational alerts).
140 pub wam: Option<wam_client::WamClient>,
141 /// Derived in-memory caches (sessions, custom domains, SyncKit SSE fan-out).
142 pub caches: AppCaches,
143 /// Concurrency limiters for memory-/process-heavy request paths.
144 pub limiters: AppLimiters,
145 /// Unix timestamp when the server will restart (0 = no restart pending).
146 /// Set by the deploy script via the internal API before uploading a new binary.
147 pub restart_at: Arc<std::sync::atomic::AtomicI64>,
148 /// Prometheus metrics handle for rendering the admin dashboard. `None` in tests.
149 pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
150 /// Bounded batcher for page-view UPSERTs. Replaces the previous
151 /// `tokio::spawn(record_view(...))` per request which under burst saturated
152 /// the DB pool. `try_record` is non-blocking; drops on overflow.
153 pub page_view_tx: db::page_views::PageViewTx,
154 /// Bounded background-task queue for fire-and-forget work (email sends,
155 /// mailing-list subscriptions, etc.). Replaces per-request `tokio::spawn`
156 /// for low-priority work; bounded queue + bounded concurrent execution
157 /// prevent burst traffic from starving the DB pool. See `background.rs`.
158 pub bg: background::BackgroundTx,
159 }
160
161 /// S3-compatible storage backends held by [`AppState`].
162 #[derive(Clone)]
163 pub struct AppStorage {
164 /// Main private bucket (uploaded originals, staged content).
165 pub s3: Option<Arc<dyn StorageBackend>>,
166 /// Separate bucket for SyncKit blob storage.
167 pub synckit_s3: Option<Arc<dyn StorageBackend>>,
168 /// Public, CDN-served bucket. Holds only promoted image content (covers,
169 /// gallery, item/project images); the scan worker copies Clean image
170 /// objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset.
171 pub public_s3: Option<Arc<dyn StorageBackend>>,
172 }
173
174 /// Derived in-memory caches held by [`AppState`]. All are `Arc<DashMap>` so a
175 /// clone of `AppState` shares one map across every handler task.
176 #[derive(Clone)]
177 pub struct AppCaches {
178 /// Recently-validated session tracking IDs → last-validated instant, to
179 /// skip a per-request DB touch. Entries older than SESSION_TOUCH_CACHE_SECS
180 /// are treated as expired.
181 pub session_cache: Arc<DashMap<UserSessionId, Instant>>,
182 /// Verified custom domains → user IDs (populated on startup, updated on
183 /// verify/delete). No TTL; invalidated explicitly on user deletion.
184 pub domain_cache: Arc<DashMap<String, db::UserId>>,
185 /// SyncKit SSE push channels. Key: (app_id, user_id); value: broadcast
186 /// sender carrying the new max `seq` after each push so a subscriber already
187 /// at that cursor can skip a redundant pull.
188 pub sync_notify: Arc<DashMap<(SyncAppId, UserId), tokio::sync::broadcast::Sender<i64>>>,
189 /// Concurrent SSE connection count per user (for rate limiting).
190 pub sse_connections: Arc<DashMap<UserId, std::sync::atomic::AtomicUsize>>,
191 /// Flattened `refs/notes/*` trees, so a log page does not re-walk the notes
192 /// tree on every request. Keyed by repo path + namespace + ref tip, which
193 /// means a moved ref is a miss rather than a stale hit and there is no
194 /// invalidation to get wrong.
195 pub notes_cache: Arc<git::notes::NotesCache>,
196 }
197
198 /// Concurrency limiters held by [`AppState`], guarding memory-/process-heavy
199 /// request paths.
200 #[derive(Clone)]
201 pub struct AppLimiters {
202 /// Limits concurrent file scans to prevent memory exhaustion (each scan
203 /// downloads up to SCAN_MAX_MEMORY_BYTES into RAM).
204 pub scan_semaphore: Arc<tokio::sync::Semaphore>,
205 /// Caps concurrent cache-miss DB lookups in `caddy-ask` so a flood of
206 /// unknown-domain queries can't saturate the pool or drive ACME issuance.
207 pub caddy_ask_semaphore: Arc<tokio::sync::Semaphore>,
208 /// Caps concurrent git smart-HTTP clone/fetch responses. Each `git
209 /// upload-pack` spawns a child process and streams a packfile that can be
210 /// arbitrarily large for a big repo; without a permit, N concurrent clones
211 /// fan out unbounded processes + memory. The packfile body is streamed (not
212 /// buffered), so this bounds the process count, not a heap ceiling.
213 pub git_smart_http_semaphore: Arc<tokio::sync::Semaphore>,
214 }
215
216 // Capability slices (decomposition Phase 1).
217 //
218 // Each of these is a logical grouping of `AppState` fields that a handler can
219 // extract as `State<Slice>` instead of the whole god struct. They are projection
220 // *views*, not physical containers: the fields keep their existing homes on
221 // `AppState` (flat, or inside `AppStorage`/`AppCaches`/`AppLimiters`), and a
222 // hand-written `FromRef<AppState>` clones the relevant ones on extraction. This
223 // is what lets a slice like `Sync` or `Scanning` draw from fields that live in
224 // different physical sub-structs. Every field here is `Arc`/pool/small-`Clone`,
225 // so the projection clone is cheap. Single-field slices (e.g. `State<PgPool>`,
226 // `State<EmailClient>`, `State<BackgroundTx>`) come free from the `AppState`
227 // derive and don't need a struct here.
228 //
229 // See `_private/docs/mnw/appstate-decomposition-plan.md`.
230
231 /// Billing/pricing slice: the Stripe provider plus the pricing tables derived
232 /// from `assumptions.toml`. Used by checkout, subscription, promo, and the
233 /// public `/pricing` page.
234 #[derive(Clone)]
235 pub struct Billing {
236 pub stripe: Option<Arc<dyn PaymentProvider>>,
237 pub tier_prices: tier_prices::TierPrices,
238 pub runway_config: tier_prices::RunwayConfig,
239 pub fee_calculator: fee_calculator::FeeCalculator,
240 }
241
242 impl FromRef<AppState> for Billing {
243 fn from_ref(s: &AppState) -> Self {
244 Self {
245 stripe: s.stripe.clone(),
246 tier_prices: s.tier_prices.clone(),
247 runway_config: s.runway_config.clone(),
248 fee_calculator: s.fee_calculator.clone(),
249 }
250 }
251 }
252
253 /// External-service integration clients (both optional, tailnet/internal).
254 #[derive(Clone)]
255 pub struct Integrations {
256 pub mt_client: Option<mt_client::MtClient>,
257 pub wam: Option<wam_client::WamClient>,
258 }
259
260 impl FromRef<AppState> for Integrations {
261 fn from_ref(s: &AppState) -> Self {
262 Self {
263 mt_client: s.mt_client.clone(),
264 wam: s.wam.clone(),
265 }
266 }
267 }
268
269 /// File-scanning slice: the scan pipeline that handlers enqueue work onto.
270 ///
271 /// Just `scanner`. The `scan_semaphore` that bounds concurrent in-memory scans
272 /// is *not* here: it is consumed only inside the scan worker
273 /// ([`scanning::worker::WorkerContext`], wired directly from [`AppLimiters`] at
274 /// startup), never by a request handler. Every handler that extracts
275 /// `State<Scanning>` reads only `scanner`, so bundling the worker-only semaphore
276 /// in would be dead weight that misstates the handler's dependencies.
277 #[derive(Clone)]
278 pub struct Scanning {
279 pub scanner: Option<Arc<ScanPipeline>>,
280 }
281
282 impl FromRef<AppState> for Scanning {
283 fn from_ref(s: &AppState) -> Self {
284 Self {
285 scanner: s.scanner.clone(),
286 }
287 }
288 }
289
290 /// SyncKit slice: the blob bucket plus the SSE push/rate-limit maps. Draws
291 /// `synckit_s3` from [`AppStorage`] and the two maps from [`AppCaches`].
292 #[derive(Clone)]
293 pub struct Sync {
294 pub synckit_s3: Option<Arc<dyn StorageBackend>>,
295 pub sync_notify: Arc<DashMap<(SyncAppId, UserId), tokio::sync::broadcast::Sender<i64>>>,
296 pub sse_connections: Arc<DashMap<UserId, std::sync::atomic::AtomicUsize>>,
297 }
298
299 impl FromRef<AppState> for Sync {
300 fn from_ref(s: &AppState) -> Self {
301 Self {
302 synckit_s3: s.storage.synckit_s3.clone(),
303 sync_notify: s.caches.sync_notify.clone(),
304 sse_connections: s.caches.sse_connections.clone(),
305 }
306 }
307 }
308
309 /// Depth of each per-(app,user) SSE broadcast channel. A subscriber that lags
310 /// past this many un-received `seq` values gets a `Lagged` error and skips to
311 /// the latest (the client pulls anyway), so the buffer only needs to cover a
312 /// brief scheduling gap between a push and the subscriber's poll.
313 const SYNC_NOTIFY_CHANNEL_DEPTH: usize = 16;
314
315 impl Sync {
316 /// Notify SSE subscribers of a push to `(app_id, user_id)`, carrying the new
317 /// max `seq` so a device already at (or past) this cursor can skip a
318 /// redundant pull. No-op when nobody is subscribed.
319 pub fn notify_push(&self, app_id: SyncAppId, user_id: UserId, cursor: i64) {
320 if let Some(sender) = self.sync_notify.get(&(app_id, user_id)) {
321 let _ = sender.send(cursor); // Err = no subscribers, which is fine.
322 }
323 }
324
325 /// Get-or-create the broadcast channel for `(app_id, user_id)` and return a
326 /// fresh receiver. The channel is created lazily on first subscribe; the
327 /// SSE connection guard prunes it once the last receiver drops.
328 pub fn subscribe_channel(
329 &self,
330 app_id: SyncAppId,
331 user_id: UserId,
332 ) -> tokio::sync::broadcast::Receiver<i64> {
333 self.sync_notify
334 .entry((app_id, user_id))
335 .or_insert_with(|| tokio::sync::broadcast::channel(SYNC_NOTIFY_CHANNEL_DEPTH).0)
336 .value()
337 .subscribe()
338 }
339 }
340
341 /// Git source-browser slice: the syntax highlighter (blob rendering) plus the
342 /// semaphore that bounds concurrent smart-HTTP clone/fetch child processes.
343 /// Draws `syntax` from the top level and `git_smart_http_semaphore` from
344 /// [`AppLimiters`].
345 #[derive(Clone)]
346 pub struct Git {
347 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
348 pub smart_http_semaphore: Arc<tokio::sync::Semaphore>,
349 pub notes_cache: Arc<git::notes::NotesCache>,
350 }
351
352 impl FromRef<AppState> for Git {
353 fn from_ref(s: &AppState) -> Self {
354 Self {
355 syntax: s.syntax.clone(),
356 smart_http_semaphore: s.limiters.git_smart_http_semaphore.clone(),
357 notes_cache: Arc::clone(&s.caches.notes_cache),
358 }
359 }
360 }
361
362 /// Observability/lifecycle slice: uptime clocks, the pending-restart flag, and
363 /// the Prometheus render handle. Used by status/health/admin/metrics handlers.
364 /// (`started_at`/`start_instant` are `#[from_ref(skip)]` on `AppState`, so this
365 /// view is the only way to extract them without the whole struct.)
366 #[derive(Clone)]
367 pub struct Ops {
368 pub started_at: chrono::DateTime<chrono::Utc>,
369 pub start_instant: Instant,
370 pub restart_at: Arc<std::sync::atomic::AtomicI64>,
371 pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
372 }
373
374 impl FromRef<AppState> for Ops {
375 fn from_ref(s: &AppState) -> Self {
376 Self {
377 started_at: s.started_at,
378 start_instant: s.start_instant,
379 restart_at: s.restart_at.clone(),
380 metrics_handle: s.metrics_handle.clone(),
381 }
382 }
383 }
384
385 /// Externally-wired dependencies handed to [`AppState::build`].
386 ///
387 /// Holds every field the caller must construct from the environment (pools,
388 /// clients, loaded config, the warmed domain cache). [`AppState::build`] assembles
389 /// these into an [`AppState`] and fills in the purely-derived in-memory state
390 /// (start timestamps, the empty cache maps, the concurrency semaphores) itself, so
391 /// those defaults live in one place instead of being spelled out at each call site.
392 pub struct AppStateParts {
393 pub db: sqlx::PgPool,
394 pub config: Config,
395 pub storage: AppStorage,
396 pub stripe: Option<Arc<dyn PaymentProvider>>,
397 pub email: EmailClient,
398 pub docs: Arc<DocLoader>,
399 pub tier_prices: tier_prices::TierPrices,
400 pub runway_config: tier_prices::RunwayConfig,
401 pub fee_calculator: fee_calculator::FeeCalculator,
402 pub scanner: Option<Arc<ScanPipeline>>,
403 pub webauthn: Arc<Webauthn>,
404 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
405 pub mt_client: Option<mt_client::MtClient>,
406 pub wam: Option<wam_client::WamClient>,
407 /// Custom-domain cache, already warmed from the DB by the caller.
408 pub domain_cache: Arc<DashMap<String, db::UserId>>,
409 pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
410 pub page_view_tx: db::page_views::PageViewTx,
411 pub bg: background::BackgroundTx,
412 }
413
414 impl AppStorage {
415 /// Get the main S3 storage backend, or error if not configured.
416 pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
417 self.s3.as_ref().ok_or_else(|| {
418 error::AppError::ServiceUnavailable("File storage is not configured".to_string())
419 })
420 }
421
422 /// Get the SyncKit S3 storage backend, or error if not configured.
423 pub fn require_synckit_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
424 self.synckit_s3.as_ref().ok_or_else(|| {
425 error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string())
426 })
427 }
428
429 /// Get the public (CDN-served) S3 storage backend, or error if not configured.
430 pub fn require_public_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
431 self.public_s3.as_ref().ok_or_else(|| {
432 error::AppError::ServiceUnavailable(
433 "Public storage bucket is not configured".to_string(),
434 )
435 })
436 }
437 }
438
439 impl AppState {
440 /// Assemble an [`AppState`] from its externally-wired dependencies.
441 ///
442 /// The caller builds the pieces that depend on the environment (DB pool, S3
443 /// clients, Stripe, loaded docs, derived pricing, the warmed domain cache) and
444 /// passes them in via [`AppStateParts`]; this fills in the purely-derived
445 /// in-memory state (start timestamps, empty session/SSE caches, concurrency
446 /// semaphores sized from [`constants`]). It has no process-global side effects:
447 /// `TierPrices::install_global` is an explicit step at the call site, kept out
448 /// of construction so the ordering stays visible.
449 pub fn build(parts: AppStateParts) -> Self {
450 Self {
451 db: parts.db,
452 config: parts.config,
453 storage: parts.storage,
454 stripe: parts.stripe,
455 email: parts.email,
456 docs: parts.docs,
457 tier_prices: parts.tier_prices,
458 runway_config: parts.runway_config,
459 fee_calculator: parts.fee_calculator,
460 scanner: parts.scanner,
461 webauthn: parts.webauthn,
462 syntax: parts.syntax,
463 started_at: chrono::Utc::now(),
464 start_instant: Instant::now(),
465 mt_client: parts.mt_client,
466 wam: parts.wam,
467 restart_at: Arc::new(std::sync::atomic::AtomicI64::new(0)),
468 metrics_handle: parts.metrics_handle,
469 page_view_tx: parts.page_view_tx,
470 bg: parts.bg,
471 caches: AppCaches {
472 session_cache: Arc::new(DashMap::new()),
473 domain_cache: parts.domain_cache,
474 sync_notify: Arc::new(DashMap::new()),
475 sse_connections: Arc::new(DashMap::new()),
476 notes_cache: Arc::new(git::notes::NotesCache::default()),
477 },
478 limiters: AppLimiters {
479 scan_semaphore: Arc::new(tokio::sync::Semaphore::new(
480 constants::SCAN_MAX_CONCURRENT,
481 )),
482 caddy_ask_semaphore: Arc::new(tokio::sync::Semaphore::new(
483 constants::CADDY_ASK_MAX_CONCURRENT,
484 )),
485 git_smart_http_semaphore: Arc::new(tokio::sync::Semaphore::new(
486 constants::GIT_SMART_HTTP_MAX_CONCURRENT,
487 )),
488 },
489 }
490 }
491
492 /// Get the main S3 storage backend, or error if not configured.
493 pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
494 self.storage.require_s3()
495 }
496
497 /// Get the SyncKit S3 storage backend, or error if not configured.
498 pub fn require_synckit_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
499 self.storage.require_synckit_s3()
500 }
501
502 /// Get the public (CDN-served) S3 storage backend, or error if not configured.
503 pub fn require_public_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
504 self.storage.require_public_s3()
505 }
506 }
507
508 /// Delete a user account and purge every derived in-memory cache keyed to it.
509 ///
510 /// This is the single deletion entry point for handlers and the scheduler. The
511 /// raw `db::users::delete_user` is `pub(crate)` so a caller cannot delete a user
512 /// and forget to purge `domain_cache`, which has no TTL and never re-validates a
513 /// hit, so a stale entry would otherwise linger until process restart (ultra-fuzz
514 /// Run 12 doubledown S-E: couple cache invalidation to the mutation). The custom
515 /// domain rows cascade at the DB layer; here we drop the matching cache entries.
516 ///
517 /// Takes the `Db` and `AppCaches` slices rather than the whole `AppState` so a
518 /// caller can hold just those (the decomposition seam); the coupling of the
519 /// mutation to the invalidation is preserved by requiring both.
520 pub async fn delete_user_account(
521 db: &sqlx::PgPool,
522 caches: &AppCaches,
523 user_id: db::UserId,
524 ) -> error::Result<()> {
525 db::users::delete_user(db, user_id).await?;
526 caches.domain_cache.retain(|_, uid| *uid != user_id);
527 Ok(())
528 }
529
530 /// Build the app router with all routes and middleware (minus tracing/TCP).
531 pub fn build_app(state: &AppState, session_layer: SessionManagerLayer<PostgresStore>) -> Router {
532 // All mutation-bearing sub-routers register through `CsrfRouter`, whose
533 // `route` method only accepts `PostureMethodRouter` values produced by
534 // the `csrf::*_csrf*` helpers. Finalising the merged tree drops the
535 // structural envelope so global middleware, static-file mounts, and
536 // the few bare GETs below can attach to a plain `Router<AppState>`.
537 let csrf_routes = csrf::CsrfRouter::new()
538 .merge(auth_routes(state.config.rate_limits))
539 .merge(api_routes(state.config.rate_limits))
540 .merge(storage_routes())
541 .merge(stripe_routes())
542 .merge(admin_routes(state.clone()))
543 .merge(synckit_routes(
544 state
545 .config
546 .synckit_jwt_secret
547 .clone()
548 .map(std::sync::Arc::new),
549 ))
550 .merge(oauth_routes())
551 .merge(postmark_routes())
552 .merge(git_issue_routes())
553 .merge(git_write_routes())
554 .merge(ota_routes())
555 .merge(build_routes());
556 // The description layer, when a screen is switched on. Inside the CSRF tree
557 // rather than beside it, so a described write is covered by the same
558 // envelope every other mutation is: `origin_gate` from `finalize` below,
559 // and the Auto token check from `nest_service`. It sat outside until
560 // 2026-08-11, which was harmless only for as long as described screens
561 // served nothing but GET.
562 //
563 // Nested as a service because the adapter routes internally and carries its
564 // own state, resolved per request; it takes none from axum. The Askama route
565 // for a described screen is not registered (see `dashboard_routes` and
566 // `public_routes`), so nothing here overlaps.
567 let csrf_routes = quasi::mounts(state)
568 .into_iter()
569 .fold(csrf_routes, |routes, (path, described)| {
570 routes.nest_service(path, described)
571 });
572 let csrf_routes = csrf_routes.finalize();
573 let app = Router::new()
574 .merge(page_routes(
575 state.config.rate_limits,
576 &state.config.quasi_screens,
577 ))
578 .merge(sso_routes())
579 .merge(csrf_routes)
580 .merge(git_routes())
581 .merge(routes::embed::embed_routes())
582 // Tier G1. Serves /spike/docs beside /docs so the two can be diffed.
583 // Mounted as a service, not merged: the adapter mounts as a fallback and
584 // this server has one. Delete with the module when G2 is answered.
585 .nest_service("/spike", quasi_spike::router(Arc::clone(&state.docs)))
586 .route(
587 "/api/openapi.json",
588 axum::routing::get(openapi::openapi_json),
589 )
590 .merge(utoipa_swagger_ui::SwaggerUi::new("/api/docs").url(
591 "/api-docs/openapi.json",
592 <openapi::ApiDoc as utoipa::OpenApi>::openapi(),
593 ))
594 // The ES module graph, served under a fingerprinted DIRECTORY.
595 //
596 // Every other static asset is referenced from a template as
597 // `path?v=<hash>`, so a week-long cache is safe: a deploy changes the
598 // URL. A module graph is the exception, and it took the whole site's
599 // JavaScript down on 2026-08-14. Only the ENTRY point is named by a
600 // template; the entry's own `import './dispatch.js'` is a bare relative
601 // URL that a deploy never changes. Cloudflare went on serving a
602 // week-old `dispatch.js` beside a fresh `index.js`, the two disagreed
603 // about an export name, and the graph failed to link with a
604 // SyntaxError. That takes down every island on the page, because
605 // `core/index.ts` side-effect-imports all of them.
606 //
607 // Putting the hash in the directory rather than in a query is what
608 // fixes it, and it fixes it for imports nobody has written yet: a
609 // relative import resolves against the document URL, so
610 // `/static/dist-ab12/core/index.js` asking for `./dispatch.js` gets
611 // `/static/dist-ab12/core/dispatch.js` for free. Every member of the
612 // graph moves together, by construction, and members from two different
613 // deploys can never meet.
614 //
615 // The long cache comes back with it, and is now honest: these URLs are
616 // immutable, because the next deploy has a different directory.
617 //
618 // `?v=` was tried first and is not enough. A middle attempt set
619 // `no-cache` on `/static/dist`, which is correct but gives up edge
620 // caching on the JS and, more to the point, does nothing about an
621 // object the CDN already holds under the old policy.
622 .nest_service(
623 concat!("/static/dist-", env!("STATIC_VERSION")),
624 tower::ServiceBuilder::new()
625 .layer(SetResponseHeaderLayer::overriding(
626 axum::http::header::CACHE_CONTROL,
627 HeaderValue::from_static("public, max-age=604800, immutable"),
628 ))
629 .service(ServeDir::new("static/dist")),
630 )
631 // The unversioned path stays, and revalidates. Nothing this build emits
632 // points at it; it is here for a page a browser is still holding from
633 // before the versioned directory existed, whose cached entry document
634 // still names `/static/dist/...`. Remove it once no such document can
635 // be in flight.
636 .nest_service(
637 "/static/dist",
638 tower::ServiceBuilder::new()
639 .layer(SetResponseHeaderLayer::overriding(
640 axum::http::header::CACHE_CONTROL,
641 HeaderValue::from_static("public, no-cache"),
642 ))
643 .service(ServeDir::new("static/dist")),
644 )
645 .nest_service(
646 "/static",
647 tower::ServiceBuilder::new()
648 .layer(SetResponseHeaderLayer::overriding(
649 axum::http::header::CACHE_CONTROL,
650 HeaderValue::from_static(
651 "public, max-age=604800, stale-while-revalidate=86400",
652 ),
653 ))
654 .service(ServeDir::new("static")),
655 )
656 .fallback(routes::custom_domain::custom_domain_fallback)
657 .with_state(state.clone());
658
659 // There is no /metrics scrape endpoint. Prometheus and Grafana were retired
660 // on 2026-07-21 and PoM is the monitoring story, so the endpoint had no
661 // consumer left. The recorder itself stays: the admin metrics dashboard
662 // renders from the same handle in-process.
663
664 // All rate limiters were registered as they were built above; start the
665 // periodic GC that sweeps their bucket maps so they don't grow unbounded for
666 // process lifetime (Run #14 CHRONIC 1). Guarded by `Once` internally.
667 crate::rate_limit::start_governor_sweeper();
668
669 // Innermost of this chain, so it runs closest to the routes: a direct
670 // navigation to a fragment endpoint is redirected after the session and
671 // access-gate layers have done their work, not instead of them.
672 app.layer(middleware::from_fn(
673 fragment_redirect::fragment_redirect_middleware,
674 ))
675 .layer(middleware::from_fn(request_timeout_middleware))
676 .layer(middleware::from_fn_with_state(
677 state.clone(),
678 access_gate::access_gate_middleware,
679 ))
680 .layer(middleware::from_fn_with_state(
681 state.clone(),
682 security_headers_middleware,
683 ))
684 .layer(middleware::from_fn(metrics::cache_control_middleware))
685 .layer(middleware::from_fn(metrics::metrics_middleware))
686 .layer(middleware::from_fn_with_state(
687 state.clone(),
688 metrics::idempotency_middleware,
689 ))
690 .layer(session_layer)
691 .layer(RequestBodyLimitLayer::new(1024 * 1024))
692 // Outermost: requests to the user-pages host (`u.makenot.work`) are
693 // served custom pages here and short-circuit before the session and
694 // access-gate layers, so that host stays cookieless and ungated.
695 // Everything else (and `/static`) falls through to the normal app.
696 .layer(middleware::from_fn_with_state(
697 state.clone(),
698 routes::user_pages::dispatch,
699 ))
700 }
701
702 /// Wall-clock ceiling on response generation. Generous, every normal page/API
703 /// handler finishes in well under this; the bound exists to catch a handler that
704 /// wedges on a stuck upstream (S3 GET, a hung DB call with no per-query timeout)
705 /// rather than to pace healthy traffic.
706 const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
707
708 /// Routes exempt from [`REQUEST_TIMEOUT`]: ones that legitimately run long or
709 /// stream open-ended bodies. git smart-HTTP (clone/pack), data exports, the
710 /// SyncKit SSE push channel, and OTA artifact transfer.
711 ///
712 /// Anchored to real route prefixes, not substrings (PERF M-1, Run #23): the old
713 /// `contains("/export")` also exempted the `/dashboard/export` page and any
714 /// future creator-controlled path containing the token, silently widening the
715 /// un-timed-out surface. The `/sync/builds` substring matched no route (build
716 /// artifacts move over the OTA path, already covered).
717 fn timeout_exempt(path: &str) -> bool {
718 path.starts_with("/git")
719 || path.starts_with("/api/export/")
720 || path.starts_with("/api/internal/creator/export/")
721 || path.starts_with("/api/sync/subscribe")
722 || path.starts_with("/api/v1/sync/subscribe")
723 || path.starts_with("/api/sync/ota")
724 || path.starts_with("/api/v1/sync/ota")
725 }
726
727 /// Global request timeout with per-route opt-out. A single hung handler used to
728 /// pin a connection (and its DB conn / scan permit) indefinitely; this bounds
729 /// every non-exempt handler to [`REQUEST_TIMEOUT`] and returns 504 if it blows
730 /// past it. Streaming-body handlers are unaffected anyway (the body flows after
731 /// this future returns), but they are listed in [`timeout_exempt`] for clarity.
732 async fn request_timeout_middleware(
733 request: axum::http::Request<axum::body::Body>,
734 next: middleware::Next,
735 ) -> axum::response::Response {
736 use axum::response::IntoResponse;
737 if timeout_exempt(request.uri().path()) {
738 return next.run(request).await;
739 }
740 match tokio::time::timeout(REQUEST_TIMEOUT, next.run(request)).await {
741 Ok(response) => response,
742 Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Request timed out").into_response(),
743 }
744 }
745
746 /// Middleware that sets security headers on all responses.
747 /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding.
748 async fn security_headers_middleware(
749 axum::extract::State(state): axum::extract::State<AppState>,
750 request: axum::http::Request<axum::body::Body>,
751 next: middleware::Next,
752 ) -> axum::response::Response {
753 let path = request.uri().path();
754 let is_embed = path.starts_with("/embed/");
755 // OAuth PKCE consent posts redirect to the RP's registered redirect_uri,
756 // which for native/desktop clients is a loopback callback per RFC 8252 §7.3.
757 // form-action applies to redirect chains (CSP3 §5.1), so a bare 'self' blocks
758 // the callback and the user sees the login form re-post with no visible
759 // effect. Widen form-action on the authorize endpoints only.
760 let is_oauth_authorize = path == "/oauth/authorize";
761 let mut response = next.run(request).await;
762 let headers = response.headers_mut();
763
764 // Violation reporting. `report-to` is the current mechanism and `report-uri`
765 // the deprecated one, and both are sent because no browser supports both:
766 // Safari still only reads report-uri. The endpoint is absolute so the
767 // Reporting-Endpoints value is a valid URL on every parser.
768 let report_endpoint = format!(
769 "{}/api/csp-report",
770 state.config.host_url.trim_end_matches('/')
771 );
772 let reporting = format!("; report-uri {report_endpoint}; report-to mnw-csp");
773 if let Ok(value) = HeaderValue::from_str(&format!("mnw-csp=\"{report_endpoint}\"")) {
774 headers.insert(
775 axum::http::header::HeaderName::from_static("reporting-endpoints"),
776 value,
777 );
778 }
779
780 if is_embed {
781 // Embed routes: framable from any origin, but otherwise locked down.
782 // `frame-ancestors *` alone (the old value) left default-src/script-src
783 // unrestricted, so an embed XSS would have had no CSP backstop. We keep
784 // inline script/style (the audio-player embed uses an inline <script> +
785 // onclick handlers and inline <style>) but block external scripts,
786 // objects, frames, and connections. Cover images come from S3/CDN over
787 // https; audio streams from same-origin /api/stream.
788 headers.insert(
789 axum::http::header::X_FRAME_OPTIONS,
790 HeaderValue::from_static("ALLOWALL"),
791 );
792 // The embedded player loads an external same-origin script
793 // (/static/embed-item-player.js) and carries no inline handlers,
794 // so scripts need 'self', not 'unsafe-inline' (which would
795 // silently block the external file and leave the player dead).
796 let embed_csp = format!(
797 "default-src 'none'; \
798 img-src 'self' data: https:; \
799 media-src 'self'; \
800 style-src 'unsafe-inline'; \
801 script-src 'self'; \
802 font-src 'self'; \
803 base-uri 'none'; \
804 form-action 'none'; \
805 frame-ancestors *{reporting}"
806 );
807 if let Ok(value) = HeaderValue::from_str(&embed_csp) {
808 headers.insert(
809 axum::http::header::HeaderName::from_static("content-security-policy"),
810 value,
811 );
812 }
813 } else {
814 // Normal routes: deny framing
815 headers.insert(
816 axum::http::header::X_FRAME_OPTIONS,
817 HeaderValue::from_static("DENY"),
818 );
819 // Build CSP with storage and payment domains
820 let s3_origin = std::env::var("S3_ENDPOINT").unwrap_or_default();
821 let s3_origin = s3_origin.as_str();
822 let cdn = state.config.cdn_base_url.as_str();
823 let storage_origins = match (s3_origin.is_empty(), cdn.is_empty()) {
824 (false, false) => format!(" {s3_origin} {cdn}"),
825 (false, true) => format!(" {s3_origin}"),
826 (true, false) => format!(" {cdn}"),
827 (true, true) => String::new(),
828 };
829 // The custom-page editor embeds a live preview served from the
830 // user-pages host, so that origin must be a permitted frame source.
831 let scheme = if state.config.host_url.starts_with("https") {
832 "https"
833 } else {
834 "http"
835 };
836 let user_pages_origin = format!("{scheme}://{}", state.config.user_pages_host);
837 let form_action = if is_oauth_authorize {
838 "'self' http://127.0.0.1:* http://[::1]:* http://localhost:*"
839 } else {
840 "'self'"
841 };
842 // script-src is 'self' (+ Stripe) with NO 'unsafe-inline': all inline
843 // on*/hx-on handlers were moved to delegated listeners in static/*.js
844 // (the data-action / data-after dispatchers in frontend/src/core), so any injected
845 // markup can no longer execute script. style-src keeps 'unsafe-inline'
846 // because inline style="" attributes are still used throughout.
847 let policy = |style_src: &str| {
848 format!(
849 "default-src 'self'; \
850 script-src 'self' https://js.stripe.com; \
851 style-src {style_src}; \
852 img-src 'self' data: https:; \
853 font-src 'self'; \
854 connect-src 'self' https://api.stripe.com{storage_origins}; \
855 media-src 'self'{storage_origins}; \
856 frame-src 'self' https://js.stripe.com {user_pages_origin}; \
857 base-uri 'self'; \
858 form-action {form_action}; \
859 frame-ancestors 'none'{reporting}"
860 )
861 };
862 if let Ok(value) = HeaderValue::from_str(&policy("'self' 'unsafe-inline'")) {
863 headers.insert(
864 axum::http::header::HeaderName::from_static("content-security-policy"),
865 value,
866 );
867 }
868 // The candidate policy, reported on but not enforced: identical except
869 // that style-src drops 'unsafe-inline'. That is the one tightening the
870 // enforced policy still owes, and it cannot be made blind — inline
871 // style="" attributes are still used throughout the templates. The
872 // reports say how many are left and where, and when they stop arriving
873 // the enforced policy can adopt it. Report-Only is also the safe channel
874 // for every later tightening, which is why it ships alongside rather
875 // than instead of.
876 if let Ok(value) = HeaderValue::from_str(&policy("'self'")) {
877 headers.insert(
878 axum::http::header::HeaderName::from_static("content-security-policy-report-only"),
879 value,
880 );
881 }
882 }
883
884 headers.insert(
885 axum::http::header::HeaderName::from_static("strict-transport-security"),
886 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
887 );
888 headers.insert(
889 axum::http::header::X_CONTENT_TYPE_OPTIONS,
890 HeaderValue::from_static("nosniff"),
891 );
892 headers.insert(
893 axum::http::header::REFERRER_POLICY,
894 HeaderValue::from_static("strict-origin-when-cross-origin"),
895 );
896 headers.insert(
897 axum::http::header::HeaderName::from_static("permissions-policy"),
898 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
899 );
900 response
901 }
902
903 #[cfg(test)]
904 mod from_ref_slices {
905 //! Compile-time proof of the decomposition seam: `#[derive(FromRef)]` on
906 //! [`AppState`] generates `FromRef<AppState>` for each field type, so a Phase 2
907 //! handler can extract a narrow slice as `State<T>` instead of the whole
908 //! struct. If any of these stop being extractable (a field renamed/removed, the
909 //! derive dropped), this fails to compile, catching it before the handler
910 //! migrations do. Type-level only: no `AppState` value or DB required.
911 use super::*;
912
913 fn assert_from_ref<T: FromRef<AppState>>() {}
914
915 #[test]
916 fn slices_are_extractable() {
917 // Single-field slices from the AppState derive.
918 assert_from_ref::<sqlx::PgPool>();
919 assert_from_ref::<Config>();
920 assert_from_ref::<EmailClient>();
921 assert_from_ref::<background::BackgroundTx>();
922 // Physical sub-structs (also from the derive).
923 assert_from_ref::<AppStorage>();
924 assert_from_ref::<AppCaches>();
925 assert_from_ref::<AppLimiters>();
926 // Phase 1 projection views (hand-written FromRef).
927 assert_from_ref::<Billing>();
928 assert_from_ref::<Integrations>();
929 assert_from_ref::<Scanning>();
930 assert_from_ref::<Sync>();
931 assert_from_ref::<Git>();
932 assert_from_ref::<Ops>();
933 // Background-worker projection views (hand-written FromRef): not
934 // extracted as axum `State<T>`, but built from `AppState` at spawn/
935 // dispatch so each worker states its deps instead of holding the struct.
936 assert_from_ref::<monitor::MonitorCtx>();
937 assert_from_ref::<build_runner::BuildCtx>();
938 }
939 }
940
941 #[cfg(test)]
942 mod timeout_exempt_tests {
943 use super::timeout_exempt;
944
945 #[test]
946 fn exempts_only_anchored_long_running_routes() {
947 // Genuinely long / streaming routes stay exempt.
948 for p in [
949 "/git/foo/bar.git/info/refs",
950 "/api/export/content",
951 "/api/internal/creator/export/sales",
952 "/api/sync/subscribe",
953 "/api/v1/sync/subscribe",
954 "/api/sync/ota/apps/x/releases",
955 "/api/v1/sync/ota/slug/macos/arm64/1.0.0",
956 ] {
957 assert!(timeout_exempt(p), "{p} should be exempt");
958 }
959 // The substring-match hazard: these contain a token but must NOT be exempt.
960 for p in [
961 "/dashboard/export", // a normal page, not a long export
962 "/u/somecreator/export-notes", // creator-controlled slug
963 "/items/sync/ota-recap", // happens to contain the token mid-path
964 ] {
965 assert!(!timeout_exempt(p), "{p} must not be exempt");
966 }
967 }
968 }
969