Skip to main content

max / makenotwork

13.6 KB · 333 lines History Blame Raw
1 //! MakeNotWork library — shared between the binary and integration tests.
2
3 pub mod access_gate;
4 pub mod auth;
5 pub mod background;
6 pub mod build_runner;
7 pub mod config;
8 pub mod constants;
9 pub mod csrf;
10 pub mod db;
11 pub mod email;
12 pub mod error;
13 pub mod git;
14 pub mod git_ssh;
15 pub mod license_templates;
16 pub mod crypto;
17 pub mod formatting;
18 pub mod helpers;
19 pub mod rate_limit;
20 pub mod import;
21 pub mod markdown;
22 pub mod metrics;
23 pub mod monitor;
24 pub mod openapi;
25 pub mod mt_client;
26 pub mod wam_client;
27 pub mod payments;
28 pub mod pricing;
29 pub mod synckit_billing;
30 pub mod scheduler;
31 pub mod routes;
32 pub mod rss;
33 pub mod scanning;
34 pub mod storage;
35 pub mod synckit_auth;
36 pub mod templates;
37 pub mod tier_prices;
38 pub mod types;
39 pub mod validation;
40 pub mod wordlist;
41
42 // Test-only lint: enforce that every Caddy site block proxying the app declares
43 // a safe IP-trust posture. Compiled only under `cargo test`.
44 #[cfg(test)]
45 mod deploy_lint;
46
47 use axum::{http::HeaderValue, middleware, Router};
48 use std::time::Instant;
49 use tower_http::limit::RequestBodyLimitLayer;
50 use tower_http::services::ServeDir;
51 use tower_http::set_header::SetResponseHeaderLayer;
52 use tower_sessions::SessionManagerLayer;
53 use tower_sessions_sqlx_store::PostgresStore;
54
55 use std::sync::Arc;
56
57 use dashmap::DashMap;
58 use db::{SyncAppId, UserSessionId, UserId};
59
60 use config::Config;
61 use docengine::DocLoader;
62 use email::EmailClient;
63 use payments::PaymentProvider;
64 use routes::{
65 admin_routes, api_routes, auth_routes, build_routes, git_routes, git_issue_routes,
66 oauth_routes, ota_routes, page_routes, postmark_routes, sso_routes, storage_routes, stripe_routes,
67 synckit_routes,
68 };
69 use scanning::ScanPipeline;
70 use storage::StorageBackend;
71 use webauthn_rs::Webauthn;
72
73 /// Application state shared across all handlers
74 #[derive(Clone)]
75 pub struct AppState {
76 pub db: sqlx::PgPool,
77 pub config: Config,
78 pub s3: Option<Arc<dyn StorageBackend>>,
79 pub synckit_s3: Option<Arc<dyn StorageBackend>>,
80 pub stripe: Option<Arc<dyn PaymentProvider>>,
81 pub email: EmailClient,
82 pub docs: Arc<DocLoader>,
83 pub tier_prices: tier_prices::TierPrices,
84 pub cost_allocation: tier_prices::CostAllocation,
85 pub runway_config: tier_prices::RunwayConfig,
86 pub scanner: Option<Arc<ScanPipeline>>,
87 pub webauthn: Arc<Webauthn>,
88 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
89 pub started_at: chrono::DateTime<chrono::Utc>,
90 pub start_instant: Instant,
91 /// Cache of recently-validated session tracking IDs to skip per-request DB touch.
92 /// Maps session tracking ID → last validated instant. Entries older than
93 /// SESSION_TOUCH_CACHE_SECS are treated as expired.
94 pub session_cache: Arc<DashMap<UserSessionId, Instant>>,
95 /// HTTP client for the Multithreaded internal API (community/thread provisioning).
96 pub mt_client: Option<mt_client::MtClient>,
97 /// HTTP client for the WAM ticket manager (operational alerts).
98 pub wam: Option<wam_client::WamClient>,
99 /// Cache of verified custom domains → user IDs (populated on startup, updated on verify/delete).
100 pub domain_cache: Arc<DashMap<String, db::UserId>>,
101 /// Limits concurrent file scans to prevent memory exhaustion (each scan
102 /// downloads up to SCAN_MAX_MEMORY_BYTES into RAM).
103 pub scan_semaphore: Arc<tokio::sync::Semaphore>,
104 /// Caps concurrent cache-miss DB lookups in `caddy-ask` so a flood of
105 /// unknown-domain queries can't saturate the pool or drive ACME issuance.
106 pub caddy_ask_semaphore: Arc<tokio::sync::Semaphore>,
107 /// Unix timestamp when the server will restart (0 = no restart pending).
108 /// Set by the deploy script via the internal API before uploading a new binary.
109 pub restart_at: Arc<std::sync::atomic::AtomicI64>,
110 /// SSE push notification channels for SyncKit subscribers.
111 /// Key: (app_id, user_id), Value: broadcast sender that SSE connections subscribe to.
112 pub sync_notify: Arc<DashMap<(SyncAppId, UserId), tokio::sync::broadcast::Sender<()>>>,
113 /// Concurrent SSE connection count per user (for rate limiting).
114 pub sse_connections: Arc<DashMap<UserId, std::sync::atomic::AtomicUsize>>,
115 /// Prometheus metrics handle for rendering the admin dashboard. `None` in tests.
116 pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
117 /// Bounded batcher for page-view UPSERTs. Replaces the previous
118 /// `tokio::spawn(record_view(...))` per request which under burst saturated
119 /// the DB pool. `try_record` is non-blocking; drops on overflow.
120 pub page_view_tx: db::page_views::PageViewTx,
121 /// Bounded background-task queue for fire-and-forget work (email sends,
122 /// mailing-list subscriptions, etc.). Replaces per-request `tokio::spawn`
123 /// for low-priority work; bounded queue + bounded concurrent execution
124 /// prevent burst traffic from starving the DB pool. See `background.rs`.
125 pub bg: background::BackgroundTx,
126 }
127
128 impl AppState {
129 /// Get the main S3 storage backend, or error if not configured.
130 pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
131 self.s3
132 .as_ref()
133 .ok_or_else(|| error::AppError::ServiceUnavailable("File storage is not configured".to_string()))
134 }
135
136 /// Get the SyncKit S3 storage backend, or error if not configured.
137 pub fn require_synckit_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
138 self.synckit_s3
139 .as_ref()
140 .ok_or_else(|| error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string()))
141 }
142 }
143
144 /// Build the app router with all routes and middleware (minus tracing/TCP).
145 pub fn build_app(
146 state: AppState,
147 session_layer: SessionManagerLayer<PostgresStore>,
148 ) -> Router {
149 let metrics_handle = state.metrics_handle.clone();
150 // All mutation-bearing sub-routers register through `CsrfRouter`, whose
151 // `route` method only accepts `PostureMethodRouter` values produced by
152 // the `csrf::*_csrf*` helpers. Finalising the merged tree drops the
153 // structural envelope so global middleware, static-file mounts, and
154 // the few bare GETs below can attach to a plain `Router<AppState>`.
155 let csrf_routes = csrf::CsrfRouter::new()
156 .merge(auth_routes())
157 .merge(api_routes())
158 .merge(storage_routes())
159 .merge(stripe_routes())
160 .merge(admin_routes())
161 .merge(synckit_routes(state.config.synckit_jwt_secret.clone().map(std::sync::Arc::new)))
162 .merge(oauth_routes())
163 .merge(postmark_routes())
164 .merge(git_issue_routes())
165 .merge(ota_routes())
166 .merge(build_routes())
167 .finalize();
168 let mut app = Router::new()
169 .merge(page_routes())
170 .merge(sso_routes())
171 .merge(csrf_routes)
172 .merge(git_routes())
173 .merge(routes::embed::embed_routes())
174 .route("/api/openapi.json", axum::routing::get(openapi::openapi_json))
175 .nest_service(
176 "/static",
177 tower::ServiceBuilder::new()
178 .layer(SetResponseHeaderLayer::overriding(
179 axum::http::header::CACHE_CONTROL,
180 HeaderValue::from_static(
181 "public, max-age=604800, stale-while-revalidate=86400",
182 ),
183 ))
184 .service(ServeDir::new("static")),
185 )
186 .nest_service(
187 "/rustdoc",
188 tower::ServiceBuilder::new()
189 .layer(SetResponseHeaderLayer::overriding(
190 axum::http::header::CACHE_CONTROL,
191 HeaderValue::from_static(
192 "public, max-age=86400, stale-while-revalidate=3600",
193 ),
194 ))
195 .service(ServeDir::new("rustdoc")),
196 )
197 .fallback(routes::custom_domain::custom_domain_fallback)
198 .with_state(state.clone());
199
200 // /metrics endpoint (Prometheus scrape target). Only available when the
201 // recorder is installed (i.e. in the real server, not in integration tests).
202 // Protected by Bearer token matching cli_service_token.
203 if let Some(handle) = metrics_handle {
204 let metrics_state = state.clone();
205 app = app.merge(
206 Router::new()
207 .route("/metrics", axum::routing::get(move |
208 axum::extract::State(prom_handle): axum::extract::State<metrics_exporter_prometheus::PrometheusHandle>,
209 headers: axum::http::HeaderMap,
210 | async move {
211 use axum::response::IntoResponse;
212 let token = headers
213 .get("authorization")
214 .and_then(|v| v.to_str().ok())
215 .and_then(|v| v.strip_prefix("Bearer "));
216 match (token, metrics_state.config.cli_service_token.as_deref()) {
217 (Some(t), Some(expected)) if crate::helpers::constant_time_compare(t, expected) => {
218 prom_handle.render().into_response()
219 }
220 _ => axum::http::StatusCode::UNAUTHORIZED.into_response(),
221 }
222 }))
223 .with_state(handle),
224 );
225 }
226
227 // All rate limiters were registered as they were built above; start the
228 // periodic GC that sweeps their bucket maps so they don't grow unbounded for
229 // process lifetime (Run #14 CHRONIC 1). Guarded by `Once` internally.
230 crate::rate_limit::start_governor_sweeper();
231
232 app.layer(middleware::from_fn_with_state(state.clone(), access_gate::access_gate_middleware))
233 .layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware))
234 .layer(middleware::from_fn(metrics::cache_control_middleware))
235 .layer(middleware::from_fn(metrics::metrics_middleware))
236 .layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware))
237 .layer(session_layer)
238 .layer(RequestBodyLimitLayer::new(1024 * 1024))
239 }
240
241 /// Middleware that sets security headers on all responses.
242 /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding.
243 async fn security_headers_middleware(
244 axum::extract::State(state): axum::extract::State<AppState>,
245 request: axum::http::Request<axum::body::Body>,
246 next: middleware::Next,
247 ) -> axum::response::Response {
248 let is_embed = request.uri().path().starts_with("/embed/");
249 let mut response = next.run(request).await;
250 let headers = response.headers_mut();
251
252 if is_embed {
253 // Embed routes: framable from any origin, but otherwise locked down.
254 // `frame-ancestors *` alone (the old value) left default-src/script-src
255 // unrestricted, so an embed XSS would have had no CSP backstop. We keep
256 // inline script/style (the audio-player embed uses an inline <script> +
257 // onclick handlers and inline <style>) but block external scripts,
258 // objects, frames, and connections. Cover images come from S3/CDN over
259 // https; audio streams from same-origin /api/stream.
260 headers.insert(
261 axum::http::header::X_FRAME_OPTIONS,
262 HeaderValue::from_static("ALLOWALL"),
263 );
264 headers.insert(
265 axum::http::header::HeaderName::from_static("content-security-policy"),
266 HeaderValue::from_static(
267 "default-src 'none'; \
268 img-src 'self' data: https:; \
269 media-src 'self'; \
270 style-src 'unsafe-inline'; \
271 script-src 'unsafe-inline'; \
272 font-src 'self'; \
273 base-uri 'none'; \
274 form-action 'none'; \
275 frame-ancestors *",
276 ),
277 );
278 } else {
279 // Normal routes: deny framing
280 headers.insert(
281 axum::http::header::X_FRAME_OPTIONS,
282 HeaderValue::from_static("DENY"),
283 );
284 // Build CSP with storage and payment domains
285 let s3_origin = std::env::var("S3_ENDPOINT").unwrap_or_default();
286 let s3_origin = s3_origin.as_str();
287 let cdn = state.config.cdn_base_url.as_deref().unwrap_or("");
288 let storage_origins = match (s3_origin.is_empty(), cdn.is_empty()) {
289 (false, false) => format!(" {s3_origin} {cdn}"),
290 (false, true) => format!(" {s3_origin}"),
291 (true, false) => format!(" {cdn}"),
292 (true, true) => String::new(),
293 };
294 let csp = format!(
295 "default-src 'self'; \
296 script-src 'self' 'unsafe-inline' https://js.stripe.com; \
297 style-src 'self' 'unsafe-inline'; \
298 img-src 'self' data: https:; \
299 font-src 'self'; \
300 connect-src 'self' https://api.stripe.com{storage_origins}; \
301 media-src 'self'{storage_origins}; \
302 frame-src 'self' https://js.stripe.com; \
303 base-uri 'self'; \
304 form-action 'self'; \
305 frame-ancestors 'none'"
306 );
307 if let Ok(value) = HeaderValue::from_str(&csp) {
308 headers.insert(
309 axum::http::header::HeaderName::from_static("content-security-policy"),
310 value,
311 );
312 }
313 }
314
315 headers.insert(
316 axum::http::header::HeaderName::from_static("strict-transport-security"),
317 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
318 );
319 headers.insert(
320 axum::http::header::X_CONTENT_TYPE_OPTIONS,
321 HeaderValue::from_static("nosniff"),
322 );
323 headers.insert(
324 axum::http::header::REFERRER_POLICY,
325 HeaderValue::from_static("strict-origin-when-cross-origin"),
326 );
327 headers.insert(
328 axum::http::header::HeaderName::from_static("permissions-policy"),
329 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
330 );
331 response
332 }
333