Skip to main content

max / makenotwork

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