Skip to main content

max / makenotwork

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