Skip to main content

max / makenotwork

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