Skip to main content

max / makenotwork

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