1#![recursion_limit = "256"]
15
16pub mod api;
17pub mod assets;
18pub mod assistant;
19pub mod auth;
20pub mod backup;
21pub mod backupcrypt;
22pub mod bomtable;
23pub mod cadformat;
24pub mod cadrefs;
25pub mod catprod;
26pub mod config;
27pub mod db;
28pub mod delivery;
29pub mod domain;
30pub mod error;
31pub mod fusion;
32#[doc(hidden)]
33pub mod fuzz_harness;
34pub mod htmltext;
35pub mod httprange;
36pub mod invasm;
37pub mod invpart;
38pub mod invplace;
39pub mod llm;
40pub mod localserver;
41pub mod malware;
42pub mod massprops;
43pub mod mattermost;
44pub mod mcp;
45pub mod mesh;
46pub mod metrics;
47pub mod models;
48pub mod notify;
49pub mod nxasm;
50pub mod nxpart;
51pub mod oidc;
52pub mod pagecontext;
53pub mod pdftext;
54pub mod preview;
55pub mod resultscan;
56pub mod services;
57pub mod shape;
58pub mod simmesh;
59pub mod solversummary;
60pub mod stepbom;
61pub mod swpart;
62pub mod templates;
63pub mod textindex;
64pub mod thumbnail;
65pub mod util;
66pub mod vault;
67pub mod web;
68pub mod webhooks;
69
70use std::{any::Any, sync::Arc};
71
72use anyhow::Context;
73use axum::{
74 extract::State,
75 http::{header, HeaderName, HeaderValue, StatusCode},
76 response::{IntoResponse, Response},
77 routing::get,
78 Json, Router,
79};
80use serde_json::json;
81use sqlx::PgPool;
82use tower_http::{
83 catch_panic::CatchPanicLayer,
84 set_header::SetResponseHeaderLayer,
85 trace::{DefaultOnResponse, TraceLayer},
86};
87use tracing::Level;
88
89use crate::{config::Config, oidc::OidcClient, preview::Previews, vault::Vault};
90
91#[derive(Clone)]
93pub struct AppState {
94 pub db: PgPool,
95 pub config: Arc<Config>,
96 pub templates: Arc<minijinja::Environment<'static>>,
97 pub vault: Vault,
98 pub oidc: Option<Arc<OidcClient>>,
99 pub previews: Arc<Previews>,
101 pub assistant: Option<Arc<assistant::Assistant>>,
103 pub webhooks: webhooks::Dispatcher,
106 pub sign_in: Arc<auth::SignInThrottle>,
108 pub metrics: Arc<metrics::Metrics>,
110}
111
112impl AppState {
113 pub async fn new(config: Config, db: PgPool) -> anyhow::Result<AppState> {
114 let vault = Vault::open(&config.vault_dir)
115 .await
116 .with_context(|| {
117 format!(
118 "cannot open the file vault at {}",
119 config.vault_dir.display()
120 )
121 })?
122 .with_scanner(config.clamd.clone());
123 let mut templates =
124 templates::environment(&config.instance_name).context("loading templates failed")?;
125 if let Some(a) = &config.assistant {
126 templates.add_global(
128 "assistant_destination",
129 format!(
130 "the AI provider configured by your administrator ({}, {})",
131 a.provider.as_str(),
132 a.host()
133 ),
134 );
135 }
136 let oidc = match &config.oidc {
137 Some(o) => Some(Arc::new(OidcClient::new(o.clone(), &config.public_url)?)),
138 None => None,
139 };
140 let previews = Arc::new(Previews::new(&vault, config.preview));
141 crate::pdftext::configure(config.preview.max_memory_bytes);
142 let assistant = match &config.assistant {
143 Some(a) => Some(Arc::new({
144 if let Some(warning) = a.insecure_key_warning() {
145 tracing::warn!("{warning}");
146 }
147 assistant::Assistant::new(a).context("setting up the assistant failed")?
148 })),
149 None => None,
150 };
151 let webhooks = webhooks::Dispatcher::from_config(&config)
152 .context("setting up the webhook delivery failed")?;
153 let sign_in = Arc::new(auth::SignInThrottle::new(
154 config.signin_max_failures,
155 auth::SIGN_IN_WINDOW,
156 ));
157 Ok(AppState {
158 db,
159 sign_in,
160 metrics: Arc::default(),
161 config: Arc::new(config),
162 templates: Arc::new(templates),
163 vault,
164 oidc,
165 previews,
166 assistant,
167 webhooks,
168 })
169 }
170}
171
172pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; img-src 'self' data:; \
175 style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; \
176 base-uri 'self'; form-action 'self'";
177
178pub fn router(state: AppState) -> Router {
180 let hsts = state.config.cookie_secure;
181 let app = Router::new()
182 .route("/healthz", get(healthz))
183 .route("/readyz", get(readyz))
184 .route("/metrics", get(metrics_endpoint))
185 .route(
188 "/auth/oidc/backchannel-logout",
189 axum::routing::post(web::oidc_backchannel_logout),
190 )
191 .nest("/api/v1", api::router(&state))
192 .merge(web::router(&state))
193 .layer(SetResponseHeaderLayer::if_not_present(
194 header::X_CONTENT_TYPE_OPTIONS,
195 HeaderValue::from_static("nosniff"),
196 ))
197 .layer(SetResponseHeaderLayer::if_not_present(
198 header::X_FRAME_OPTIONS,
199 HeaderValue::from_static("DENY"),
200 ))
201 .layer(SetResponseHeaderLayer::if_not_present(
202 header::REFERRER_POLICY,
203 HeaderValue::from_static("same-origin"),
204 ))
205 .layer(SetResponseHeaderLayer::if_not_present(
206 header::CONTENT_SECURITY_POLICY,
207 HeaderValue::from_static(CONTENT_SECURITY_POLICY),
208 ))
209 .layer(SetResponseHeaderLayer::if_not_present(
210 HeaderName::from_static("permissions-policy"),
211 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
212 ))
213 .layer(tower::util::option_layer(hsts.then(|| {
214 SetResponseHeaderLayer::if_not_present(
215 header::STRICT_TRANSPORT_SECURITY,
216 HeaderValue::from_static("max-age=31536000"),
217 )
218 })));
219 guarded(app, state.metrics.clone())
220 .layer(
221 TraceLayer::new_for_http()
224 .make_span_with(|req: &axum::http::Request<axum::body::Body>| {
225 tracing::info_span!("http", method = %req.method(), path = %req.uri().path())
226 })
227 .on_request(())
228 .on_response(DefaultOnResponse::new().level(Level::INFO)),
229 )
230 .with_state(state)
231}
232
233async fn healthz() -> &'static str {
235 "ok"
236}
237
238async fn readyz(State(state): State<AppState>) -> Response {
240 let db = sqlx::query_scalar::<_, i32>("SELECT 1")
241 .fetch_one(&state.db)
242 .await;
243 let vault = state.vault.check_writable().await;
244 let ok = db.is_ok() && vault.is_ok();
245 let body = json!({
246 "status": if ok { "ok" } else { "unavailable" },
247 "database": if db.is_ok() { "ok" } else { "error" },
248 "vault": if vault.is_ok() { "ok" } else { "error" },
249 "version": env!("CARGO_PKG_VERSION"),
250 });
251 let status = if ok {
252 StatusCode::OK
253 } else {
254 StatusCode::SERVICE_UNAVAILABLE
255 };
256 (status, Json(body)).into_response()
257}
258
259fn panic_response(err: Box<dyn Any + Send + 'static>) -> Response {
260 let detail = if let Some(s) = err.downcast_ref::<String>() {
261 s.clone()
262 } else if let Some(s) = err.downcast_ref::<&str>() {
263 s.to_string()
264 } else {
265 "unknown panic".to_string()
266 };
267 tracing::error!(panic = %detail, "request handler panicked");
268 notify::report_error(&format!("panic: {detail}"));
269 (
270 StatusCode::INTERNAL_SERVER_ERROR,
271 Json(json!({ "error": { "code": "internal", "message": "Internal server error." } })),
272 )
273 .into_response()
274}
275
276async fn metrics_endpoint(
279 State(state): State<AppState>,
280 headers: axum::http::HeaderMap,
281) -> Response {
282 let Some(expected) = state.config.metrics_token.as_deref() else {
283 return StatusCode::NOT_FOUND.into_response();
284 };
285 let given = headers
286 .get(header::AUTHORIZATION)
287 .and_then(|v| v.to_str().ok());
288 if !metrics::token_matches(given, expected) {
289 return (
290 StatusCode::UNAUTHORIZED,
291 [
292 (header::WWW_AUTHENTICATE, "Bearer"),
293 (header::CACHE_CONTROL, "no-store"),
294 ],
295 "metrics token required\n",
296 )
297 .into_response();
298 }
299 let count = |gauge: &'static str, sql: &'static str| {
300 let pool = state.db.clone();
301 async move {
302 match sqlx::query_scalar::<_, i64>(sql).fetch_one(&pool).await {
303 Ok(n) => n as f64,
304 Err(e) => {
305 tracing::warn!(gauge, error = %e, "metrics: counting the backlog failed");
307 f64::NAN
308 }
309 }
310 }
311 };
312 let (webhooks, mail, chat) = tokio::join!(
313 count(
314 "teamcraft_webhook_deliveries_pending",
315 "SELECT COUNT(*) FROM webhook_deliveries WHERE status = 'pending'"
316 ),
317 count(
318 "teamcraft_notification_emails_pending",
319 "SELECT COUNT(*) FROM notifications WHERE email_state = 'pending'"
320 ),
321 count(
322 "teamcraft_notification_chat_pending",
323 "SELECT COUNT(*) FROM notifications WHERE chat_state = 'pending'"
324 ),
325 );
326 let (previews_running, previews_waiting) = state.previews.queue();
327 let gauges = [
328 (
329 "teamcraft_db_pool_connections",
330 "Open database connections.",
331 f64::from(state.db.size()),
332 ),
333 (
334 "teamcraft_db_pool_idle_connections",
335 "Idle database connections.",
336 state.db.num_idle() as f64,
337 ),
338 (
339 "teamcraft_webhook_deliveries_pending",
340 "Webhook deliveries waiting to be sent.",
341 webhooks,
342 ),
343 (
344 "teamcraft_notification_emails_pending",
345 "E-mails waiting to be sent.",
346 mail,
347 ),
348 (
349 "teamcraft_notification_chat_pending",
350 "Chat messages waiting to be sent.",
351 chat,
352 ),
353 (
354 "teamcraft_preview_conversions_running",
355 "3D preview conversions running now (this server).",
356 previews_running as f64,
357 ),
358 (
359 "teamcraft_preview_conversions_waiting",
360 "3D preview conversions waiting for a free slot (this server).",
361 previews_waiting as f64,
362 ),
363 ];
364 (
365 [
366 (
367 header::CONTENT_TYPE,
368 "text/plain; version=0.0.4; charset=utf-8",
369 ),
370 (header::CACHE_CONTROL, "no-store"),
371 ],
372 state.metrics.render(env!("CARGO_PKG_VERSION"), &gauges),
373 )
374 .into_response()
375}
376
377fn guarded<S: Clone + Send + Sync + 'static>(
381 router: Router<S>,
382 metrics: Arc<metrics::Metrics>,
383) -> Router<S> {
384 router.layer(CatchPanicLayer::custom(panic_response)).layer(
385 axum::middleware::from_fn_with_state(metrics, count_requests),
386 )
387}
388
389async fn count_requests(
392 State(metrics): State<Arc<metrics::Metrics>>,
393 request: axum::extract::Request,
394 next: axum::middleware::Next,
395) -> Response {
396 let started = std::time::Instant::now();
397 let response = next.run(request).await;
398 metrics.record(response.status().as_u16(), started.elapsed());
399 response
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use tower::ServiceExt;
406
407 async fn boom() -> &'static str {
408 panic!("handler bug")
409 }
410
411 #[tokio::test]
414 async fn a_panicking_request_is_counted_as_5xx() {
415 let metrics = Arc::new(metrics::Metrics::default());
416 let app = guarded(
417 Router::new()
418 .route("/boom", get(boom))
419 .route("/ok", get(healthz)),
420 metrics.clone(),
421 );
422 let request = |uri: &str| {
423 axum::http::Request::builder()
424 .uri(uri)
425 .body(axum::body::Body::empty())
426 .unwrap()
427 };
428 let answer = app.clone().oneshot(request("/boom")).await.unwrap();
429 assert_eq!(answer.status(), StatusCode::INTERNAL_SERVER_ERROR);
430 let answer = app.oneshot(request("/ok")).await.unwrap();
431 assert_eq!(answer.status(), StatusCode::OK);
432 let text = metrics.render("t", &[]);
433 assert!(
434 text.contains("teamcraft_http_requests_total{class=\"5xx\"} 1"),
435 "{text}"
436 );
437 assert!(text.contains("teamcraft_http_requests_total{class=\"2xx\"} 1"));
438 assert!(text.contains("teamcraft_http_request_duration_seconds_count 2"));
439 }
440}