Skip to main content

teamcraft/
lib.rs

1//! TeamCraft – an open-source product lifecycle management (PLM) system.
2//!
3//! The crate is organised in layers:
4//!
5//! * [`domain`] – pure rules (roles, lifecycle, numbering, validation)
6//! * [`services`] – business logic on top of PostgreSQL, used by both front ends
7//! * [`web`] – server-rendered HTML user interface (cookie sessions)
8//! * [`api`] – JSON REST API under `/api/v1` (bearer tokens)
9//! * [`mcp`] – Model Context Protocol server at `/api/v1/mcp` for AI assistants
10//! * [`assistant`] – built-in assistant (Claude via the Anthropic API) using the MCP tools
11
12// The `json!` endpoint index in `api::index` (`GET /api/v1`) outgrew the
13// default limit of 128.
14#![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/// Shared state of the HTTP server.
92#[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    /// Cached 3D preview meshes (see `preview`).
100    pub previews: Arc<Previews>,
101    /// The built-in assistant; `None` without `TEAMCRAFT_ANTHROPIC_API_KEY`.
102    pub assistant: Option<Arc<assistant::Assistant>>,
103    /// Sends outgoing event webhooks ("Send test", "Retry"; the background
104    /// task has its own).
105    pub webhooks: webhooks::Dispatcher,
106    /// Failed password sign-ins per username and client address.
107    pub sign_in: Arc<auth::SignInThrottle>,
108    /// Request counters for `GET /metrics`.
109    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            // privacy wording of the assistant panels: where the data goes
127            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
172/// Content-Security-Policy of every response that does not set its own
173/// (the desktop sign-in page derives its policy from it).
174pub 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
178/// Builds the complete HTTP application.
179pub 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        // the identity provider posts here; outside the web pages' cross-site
186        // guard, the signed logout token is the credential (issue #173)
187        .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            // One INFO line per request. Only the path is recorded: query
222            // strings can carry one-time OIDC codes and search terms.
223            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
233/// Liveness probe: the process is up and serving HTTP.
234async fn healthz() -> &'static str {
235    "ok"
236}
237
238/// Readiness probe: database reachable and vault writable.
239async 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
276/// Prometheus metrics: 404 unless `TEAMCRAFT_METRICS_TOKEN` is set, 401
277/// without that token as bearer token (see `metrics`).
278async 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                    // the gauge shows NaN; the log says why
306                    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
377/// Turns a panic into a 500 page and counts every answered request for
378/// `/metrics`. The counter sits outside the panic catcher, so a panicking
379/// handler is counted as `5xx` (see the test below).
380fn 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
389/// Counts every answered request for `/metrics`. The latency ends when the
390/// response headers are ready; a streamed body (downloads) is not included.
391async 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    /// The point of the layer order: a panic is caught inside the counter,
412    /// so it arrives there as a 500 and is counted as `5xx`.
413    #[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}