Skip to main content

max / makenotwork

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