Skip to main content

teamcraft/web/
mod.rs

1//! Server-rendered HTML user interface.
2//!
3//! Conventions:
4//! * every page handler takes a [`WebCtx`] (current user + flash message);
5//! * state changing requests are `POST` forms followed by a redirect
6//!   (post/redirect/get) that carries a flash message in a short-lived cookie;
7//! * user errors (validation, conflicts, permissions) of actions redirect back
8//!   with an error flash, internal errors render the error page.
9
10mod account;
11mod admin;
12// the audit log export is shared with the REST API
13pub(crate) use admin::{audit_download, audit_filter};
14mod as_built;
15mod assistant;
16mod attributes;
17mod auth_pages;
18mod bom;
19mod branding;
20mod changes;
21mod dashboard;
22mod desktop;
23mod explorer;
24mod formats;
25mod imports;
26mod interchange;
27mod items;
28mod mass;
29mod notifications;
30mod personal;
31mod references;
32mod requests;
33mod revisions;
34mod season;
35mod simulations;
36mod uploads;
37mod webhooks;
38
39// posted by the identity provider: mounted outside the cross-site guard
40pub(crate) use auth_pages::oidc_backchannel_logout;
41pub(crate) use bom::{package_response, package_rule};
42pub(crate) use branding::image as branding_image;
43pub(crate) use imports::{options_from as import_options, receive as receive_import};
44pub(crate) use requests::{attachment_response, receive_attachment};
45pub(crate) use revisions::{
46    file_response, mesh_response, receive_uploads, take_session_uploads, thumbnail_response,
47    upload_files, version_response, warm_previews, Received,
48};
49
50use axum::{
51    extract::{DefaultBodyLimit, FromRequestParts, Request, State},
52    http::{header, request::Parts, HeaderMap, HeaderValue, Method, StatusCode, Uri},
53    middleware::{self, Next},
54    response::{Html, IntoResponse, Redirect, Response},
55    routing::{get, post},
56    Router,
57};
58use serde::{Deserialize, Serialize};
59use serde_json::{json, Value};
60use uuid::Uuid;
61
62use crate::{
63    assets,
64    auth::{self, Actor, WebUser},
65    domain,
66    error::{AppError, AppResult, ErrorInfo},
67    util, AppState,
68};
69
70/// Cookie that remembers the skin for pages without a session (login, errors).
71const SKIN_COOKIE: &str = "tc_skin";
72
73/// Items per page in list views.
74pub(crate) const PER_PAGE: i64 = 50;
75
76pub fn router(state: &AppState) -> Router<AppState> {
77    // Multipart overhead on top of the configured file size limit.
78    let upload_limit = usize::try_from(state.config.max_upload_bytes)
79        .unwrap_or(usize::MAX)
80        .saturating_add(1024 * 1024);
81    let import_limit = crate::services::imports::MAX_STEP_BYTES + 1024 * 1024;
82    let attachment_limit = crate::services::requests::MAX_ATTACHMENT_BYTES + 1024 * 1024;
83
84    Router::new()
85        .route("/", get(dashboard::show))
86        .route("/checkouts/checkin-all", post(dashboard::checkin_all))
87        .route("/vault", get(explorer::show))
88        .route("/assistant", get(assistant::show).post(assistant::ask))
89        .route("/assistant/thread", get(assistant::thread))
90        .route("/assistant/thread/reset", post(assistant::reset))
91        .route("/assistant/chat", post(assistant::chat))
92        // authentication
93        .route(
94            "/login",
95            get(auth_pages::login_page).post(auth_pages::login_submit),
96        )
97        .route("/logout", post(auth_pages::logout))
98        .route("/setup", post(auth_pages::setup_submit))
99        .route("/auth/oidc/login", get(auth_pages::oidc_start))
100        .route("/auth/oidc/callback", get(auth_pages::oidc_callback))
101        .route("/auth/oidc/reauth", get(auth_pages::oidc_reauth))
102        // items
103        .route(
104            "/items",
105            get(items::list)
106                .post(items::create)
107                .layer(DefaultBodyLimit::max(upload_limit)),
108        )
109        // chunked, resumable uploads of large files (static/app.js)
110        .route("/uploads", post(uploads::create))
111        .route(
112            "/uploads/{id}",
113            get(uploads::status)
114                .patch(uploads::append)
115                .delete(uploads::abort),
116        )
117        .route("/items/new", get(items::new_form))
118        .route("/items/{id}", get(items::show))
119        .route("/items/{id}/label", get(items::label))
120        .route("/items/{id}/qr.svg", get(items::qr_svg))
121        .route("/items/{id}/master", post(items::update_master))
122        .route("/items/{id}/owner", post(items::set_owner))
123        .route("/items/{id}/delete", post(items::delete))
124        // alternates, supersession, multi-level where-used, replace in assemblies
125        .route("/items/{id}/where-used", get(interchange::where_used))
126        .route("/items/{id}/replace", post(interchange::replace))
127        .route("/items/{id}/alternates", post(interchange::add_alternate))
128        .route(
129            "/items/{id}/alternates/{alternate}/delete",
130            post(interchange::delete_alternate),
131        )
132        .route("/items/{id}/supersede", post(interchange::supersede))
133        .route(
134            "/items/{id}/supersede/delete",
135            post(interchange::clear_supersession),
136        )
137        .route("/i/{number}", get(items::by_number))
138        .route("/formats", get(formats::show))
139        // revisions & files
140        .route("/revisions/{id}", get(revisions::show))
141        .route(
142            "/revisions/{id}/edit",
143            get(revisions::edit_form).post(revisions::update),
144        )
145        .route("/revisions/{id}/checkout", post(revisions::checkout))
146        .route(
147            "/revisions/{id}/checkin",
148            post(revisions::checkin).layer(DefaultBodyLimit::max(upload_limit)),
149        )
150        .route(
151            "/revisions/{id}/cancel-checkout",
152            post(revisions::cancel_checkout),
153        )
154        .route("/revisions/{id}/revise", post(revisions::revise))
155        .route("/revisions/{id}/obsolete", post(revisions::obsolete))
156        .route(
157            "/revisions/{id}/files",
158            post(revisions::upload).layer(DefaultBodyLimit::max(upload_limit)),
159        )
160        .route(
161            "/revisions/{id}/add-to-change",
162            post(changes::add_from_revision),
163        )
164        .route("/files/{id}/download", get(revisions::download))
165        .route("/files/{id}/thumbnail", get(revisions::thumbnail))
166        .route("/files/{id}/delete", post(revisions::delete_file))
167        .route("/files/{id}/folder", post(revisions::set_file_folder))
168        .route("/files/{id}/retention", post(revisions::set_file_retention))
169        .route("/files/{id}/restore", post(revisions::restore_version))
170        .route("/files/{id}/preview", get(revisions::preview))
171        .route("/files/{id}/mesh", get(revisions::mesh))
172        .route(
173            "/file-versions/{id}/download",
174            get(revisions::download_version),
175        )
176        .route(
177            "/file-versions/{id}/preview",
178            get(revisions::preview_version),
179        )
180        .route("/file-versions/{id}/mesh", get(revisions::mesh_version))
181        .route(
182            "/file-versions/{id}/annotations",
183            get(revisions::annotations).post(revisions::add_annotation),
184        )
185        .route(
186            "/annotations/{id}/delete",
187            post(revisions::remove_annotation),
188        )
189        // BOM
190        .route("/revisions/{id}/bom", get(bom::show))
191        .route(
192            "/revisions/{id}/bom/from-references",
193            get(bom::from_references).post(bom::apply_from_references),
194        )
195        .route("/revisions/{id}/bom/compare", get(personal::bom_compare))
196        .route(
197            "/revisions/{id}/bom/compare.xlsx",
198            get(personal::bom_compare_xlsx),
199        )
200        .route(
201            "/revisions/{id}/save-as",
202            get(personal::save_as_form).post(personal::save_as_submit),
203        )
204        .route("/items/{id}/favorite", post(personal::set_favorite))
205        .route("/searches", post(personal::save_search))
206        .route("/searches/{id}/delete", post(personal::delete_search))
207        // BOM import from CAD exports
208        .route(
209            "/import",
210            get(imports::upload_form)
211                .post(imports::upload)
212                .layer(DefaultBodyLimit::max(import_limit)),
213        )
214        .route("/import/{id}", get(imports::preview))
215        .route("/import/{id}/run", post(imports::run))
216        .route("/import/{id}/discard", post(imports::discard))
217        // mass properties
218        .route("/revisions/{id}/mass", get(mass::show).post(mass::update))
219        .route("/revisions/{id}/mass/geometry", post(mass::choose_geometry))
220        .route("/revisions/{id}/mass.csv", get(mass::csv))
221        .route("/revisions/{id}/mass.json", get(mass::json_export))
222        .route(
223            "/materials",
224            get(mass::materials).post(mass::create_material),
225        )
226        .route("/materials/{id}", post(mass::update_material))
227        .route("/materials/{id}/delete", post(mass::delete_material))
228        .route("/revisions/{id}/bom.csv", get(bom::csv))
229        .route("/revisions/{id}/bom.xlsx", get(bom::xlsx))
230        .route("/revisions/{id}/package.zip", get(bom::package))
231        .route("/revisions/{id}/bom/lines", post(bom::add_line))
232        .route("/bom-lines/{id}", post(bom::update_line))
233        .route("/bom-lines/{id}/delete", post(bom::delete_line))
234        .route("/revisions/{id}/references", post(references::add))
235        .route("/references/{id}/delete", post(references::delete))
236        .route("/impacts/{id}/acknowledge", post(references::acknowledge))
237        .route(
238            "/revisions/{id}/impacts/acknowledge",
239            post(references::acknowledge_all),
240        )
241        // as-built: build record, deviations, recording a unit of a design
242        .route(
243            "/revisions/{id}/as-built/record",
244            post(as_built::set_record),
245        )
246        .route(
247            "/revisions/{id}/as-built/deviations",
248            post(as_built::add_deviation),
249        )
250        .route(
251            "/as-built-deviations/{id}",
252            post(as_built::update_deviation),
253        )
254        .route(
255            "/as-built-deviations/{id}/delete",
256            post(as_built::delete_deviation),
257        )
258        .route(
259            "/as-built-deviations/{id}/confirm",
260            post(as_built::confirm_concession),
261        )
262        .route("/revisions/{id}/built-units", post(as_built::record_unit))
263        // simulations: basis links, result values, comparison
264        .route(
265            "/revisions/{id}/simulation/bases",
266            post(simulations::add_basis),
267        )
268        .route(
269            "/simulation-bases/{id}/delete",
270            post(simulations::delete_basis),
271        )
272        .route(
273            "/revisions/{id}/simulation/results",
274            post(simulations::add_result),
275        )
276        .route(
277            "/revisions/{id}/simulation/results/import",
278            post(simulations::import_results).layer(DefaultBodyLimit::max(import_limit)),
279        )
280        .route(
281            "/revisions/{id}/simulation/results.csv",
282            get(simulations::results_csv),
283        )
284        .route(
285            "/revisions/{id}/simulation/compare",
286            get(simulations::compare),
287        )
288        .route("/simulation-results/{id}", post(simulations::update_result))
289        .route(
290            "/simulation-results/{id}/delete",
291            post(simulations::delete_result),
292        )
293        .route(
294            "/revisions/{id}/simulation/fill-with-ai",
295            post(simulations::fill_with_ai),
296        )
297        .route(
298            "/simulation-proposals/{id}/accept",
299            post(simulations::accept_proposal),
300        )
301        .route(
302            "/simulation-proposals/{id}/reject",
303            post(simulations::reject_proposal),
304        )
305        .route("/notifications", get(notifications::page))
306        .route("/notifications/read-all", post(notifications::read_all))
307        .route("/notifications/{id}/open", get(notifications::open))
308        .route("/items/{id}/watch", post(notifications::watch_item))
309        .route("/changes/{id}/watch", post(notifications::watch_change))
310        .route(
311            "/account/notifications",
312            post(notifications::update_settings),
313        )
314        .route(
315            "/account/notifications/test",
316            post(notifications::send_test),
317        )
318        // changes
319        .route("/changes", get(changes::list).post(changes::create))
320        .route("/changes/new", get(changes::new_form))
321        .route("/changes/{id}", get(changes::show).post(changes::update))
322        .route("/changes/{id}/items", post(changes::add_item))
323        .route("/changes/{id}/actions", post(changes::add_action))
324        .route(
325            "/changes/{id}/dependencies",
326            post(changes::add_dependencies),
327        )
328        .route(
329            "/changes/{id}/items/{revision_id}/remove",
330            post(changes::remove_item),
331        )
332        .route("/changes/{id}/reviewers", post(changes::add_reviewer))
333        .route(
334            "/changes/{id}/reviewers/{user_id}/remove",
335            post(changes::remove_reviewer),
336        )
337        .route(
338            "/changes/{id}/reviewers/{user_id}/replace",
339            post(changes::replace_reviewer),
340        )
341        .route("/changes/{id}/submit", post(changes::submit))
342        .route("/changes/{id}/review", post(changes::review))
343        .route("/changes/{id}/withdraw", post(changes::withdraw))
344        .route("/changes/{id}/reopen", post(changes::reopen))
345        .route("/changes/{id}/cancel", post(changes::cancel))
346        .route("/changes/{id}/take-over", post(changes::take_over))
347        // change requests (ECR)
348        .route("/requests", get(requests::list).post(requests::create))
349        .route("/requests/new", get(requests::new_form))
350        .route("/requests/{id}", get(requests::show).post(requests::update))
351        .route("/requests/{id}/comments", post(requests::comment))
352        .route("/requests/{id}/triage", post(requests::triage))
353        .route("/requests/{id}/assign", post(requests::assign))
354        .route("/requests/{id}/decide", post(requests::decide))
355        .route("/requests/{id}/reopen", post(requests::reopen))
356        .route("/requests/{id}/withdraw", post(requests::withdraw))
357        .route(
358            "/requests/{id}/create-change",
359            post(requests::create_change),
360        )
361        .route("/requests/{id}/link-change", post(requests::link_change))
362        .route("/requests/{id}/items", post(requests::add_item))
363        .route(
364            "/requests/{id}/items/{item_id}/remove",
365            post(requests::remove_item),
366        )
367        .route(
368            "/requests/{id}/attachments",
369            post(requests::attach).layer(DefaultBodyLimit::max(attachment_limit)),
370        )
371        .route("/requests/{id}/watch", post(notifications::watch_request))
372        .route(
373            "/request-attachments/{id}",
374            get(requests::download_attachment),
375        )
376        .route(
377            "/request-attachments/{id}/delete",
378            post(requests::delete_attachment),
379        )
380        // projects & administration
381        .route(
382            "/projects",
383            get(admin::projects).post(admin::create_project),
384        )
385        .route(
386            "/projects/{id}",
387            get(admin::project_show).post(admin::update_project),
388        )
389        .route("/projects/{id}/numbering", post(admin::update_numbering))
390        .route("/projects/{id}/visibility", post(admin::update_visibility))
391        .route("/projects/{id}/policy", post(admin::update_policy))
392        .route("/projects/{id}/access", post(admin::update_access))
393        .route("/projects/{id}/conditions", post(admin::update_conditions))
394        .route(
395            "/projects/{id}/access-review",
396            get(admin::access_review).post(admin::confirm_access_review),
397        )
398        .route(
399            "/projects/{id}/access-review.csv",
400            get(admin::access_review_csv),
401        )
402        .route("/projects/{id}/access-log", get(admin::access_log_page))
403        .route(
404            "/projects/{id}/review-policy",
405            post(admin::update_review_policy),
406        )
407        .route("/projects/{id}/elevate", post(admin::elevate))
408        .route("/projects/{id}/elevate/end", post(admin::end_elevation))
409        .route("/projects/{id}/members", post(admin::add_member))
410        .route(
411            "/projects/{id}/result-templates",
412            post(admin::add_result_template),
413        )
414        .route(
415            "/result-templates/{id}/delete",
416            post(admin::delete_result_template),
417        )
418        .route(
419            "/projects/{id}/members/{user_id}/remove",
420            post(admin::remove_member),
421        )
422        .route(
423            "/projects/{id}/members/{user_id}/demote",
424            post(admin::demote_member),
425        )
426        .route(
427            "/projects/{id}/members/{user_id}/release-checkouts",
428            post(admin::release_member_checkouts),
429        )
430        .route("/projects/{id}/season", get(season::page).post(season::run))
431        .route("/admin/users", get(admin::users).post(admin::create_user))
432        .route(
433            "/admin/users/{id}",
434            get(admin::user_edit).post(admin::update_user),
435        )
436        .route("/admin/users/{id}/password", post(admin::reset_password))
437        .route("/admin/users/{id}/handover", post(admin::handover))
438        .route("/admin/users/{id}/offboard", post(admin::offboard))
439        .route(
440            "/admin/users/{id}/release-checkouts",
441            post(admin::release_user_checkouts),
442        )
443        .route("/users/{id}/holdings", get(admin::user_holdings))
444        .route("/admin/audit", get(admin::audit))
445        .route("/admin/audit/export", get(admin::audit_export))
446        .route("/admin/ai", get(admin::ai))
447        .route("/admin/branding", get(branding::page))
448        .route("/admin/branding/name", post(branding::set_name))
449        .route(
450            "/admin/branding/{kind}",
451            post(branding::upload).layer(DefaultBodyLimit::max(
452                crate::services::branding::MAX_LOGO_BYTES + 64 * 1024,
453            )),
454        )
455        .route("/admin/branding/{kind}/reset", post(branding::reset))
456        .route("/branding/{kind}", get(branding::logo))
457        .route(
458            "/admin/attributes",
459            get(attributes::list).post(attributes::create),
460        )
461        .route(
462            "/admin/attributes/{id}",
463            get(attributes::show).post(attributes::update),
464        )
465        .route(
466            "/admin/webhooks",
467            get(webhooks::list).post(webhooks::create),
468        )
469        .route(
470            "/admin/webhooks/{id}",
471            get(webhooks::show).post(webhooks::update),
472        )
473        .route("/admin/webhooks/{id}/delete", post(webhooks::delete))
474        .route(
475            "/admin/webhooks/{id}/rotate-secret",
476            post(webhooks::rotate_secret),
477        )
478        .route("/admin/webhooks/{id}/test", post(webhooks::test))
479        .route(
480            "/admin/webhooks/{id}/deliveries/{delivery_id}/retry",
481            post(webhooks::retry),
482        )
483        // own account
484        .route("/account", get(account::show))
485        .route("/account/password", post(account::change_password))
486        .route("/account/tokens", post(account::create_token))
487        .route("/account/tokens/{id}/revoke", post(account::revoke_token))
488        .route("/account/skin", post(account::set_skin))
489        .route("/account/preferred-cad", post(account::set_preferred_cad))
490        // sign-in of the desktop app
491        .route(
492            "/desktop/authorize",
493            get(desktop::authorize_page).post(desktop::authorize_submit),
494        )
495        // static assets
496        .route("/static/style.css", get(static_asset))
497        .route("/static/app.js", get(static_asset))
498        .route("/static/media/{*path}", get(static_asset))
499        .route("/static/shared/{*path}", get(static_asset))
500        .route("/static/favicon.svg", get(favicon))
501        .route("/favicon.ico", get(favicon))
502        .fallback(not_found)
503        .layer(middleware::from_fn_with_state(state.clone(), csrf_guard))
504        .layer(middleware::from_fn_with_state(state.clone(), html_errors))
505        .layer(middleware::from_fn(script_redirects))
506}
507
508/// Header set by `static/app.js` when it sends a form itself (uploads with
509/// a progress bar and a cancel button).
510pub(crate) const SCRIPT_HEADER: &str = "x-teamcraft-script";
511
512/// A script-sent form gets the redirect target as JSON instead of a `303`,
513/// which XMLHttpRequest would follow on its own and thereby consume the flash
514/// message; the script then navigates there. Cookies (the flash) stay.
515async fn script_redirects(req: Request, next: Next) -> Response {
516    let scripted = req.headers().contains_key(SCRIPT_HEADER);
517    let response = next.run(req).await;
518    if !scripted || response.status() != StatusCode::SEE_OTHER {
519        return response;
520    }
521    let location = response
522        .headers()
523        .get(header::LOCATION)
524        .and_then(|v| v.to_str().ok())
525        .unwrap_or("/")
526        .to_string();
527    let cookies: Vec<HeaderValue> = response
528        .headers()
529        .get_all(header::SET_COOKIE)
530        .iter()
531        .cloned()
532        .collect();
533    let mut json = axum::Json(json!({ "location": location })).into_response();
534    for cookie in cookies {
535        json.headers_mut().append(header::SET_COOKIE, cookie);
536    }
537    json.headers_mut()
538        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
539    json
540}
541
542// ---------------------------------------------------------------------------
543// Page context & rendering
544// ---------------------------------------------------------------------------
545
546#[derive(Clone, Debug, Serialize, Deserialize)]
547pub struct Flash {
548    /// `success`, `error` or `info`
549    pub kind: String,
550    pub message: String,
551}
552
553impl Flash {
554    pub fn success(message: impl Into<String>) -> Self {
555        Flash {
556            kind: "success".into(),
557            message: message.into(),
558        }
559    }
560
561    pub fn error(message: impl Into<String>) -> Self {
562        Flash {
563            kind: "error".into(),
564            message: message.into(),
565        }
566    }
567
568    pub fn info(message: impl Into<String>) -> Self {
569        Flash {
570            kind: "info".into(),
571            message: message.into(),
572        }
573    }
574}
575
576/// The signed-in user plus a pending flash message and the number of unread
577/// notifications (bell in the header).
578pub struct WebCtx {
579    pub me: Actor,
580    pub flash: Option<Flash>,
581    pub unread: i64,
582    /// Replaced logos for the header and footer.
583    pub branding: crate::services::branding::Branding,
584    /// Path and query of this request: the `back` target of the forms in the
585    /// header (appearance), so they return here without JavaScript too.
586    pub here: String,
587}
588
589impl FromRequestParts<AppState> for WebCtx {
590    type Rejection = AppError;
591
592    async fn from_request_parts(
593        parts: &mut Parts,
594        state: &AppState,
595    ) -> Result<Self, Self::Rejection> {
596        let WebUser(me) = WebUser::from_request_parts(parts, state).await?;
597        let unread = crate::services::notifications::unread_count(&state.db, &me).await?;
598        let branding = crate::services::branding::current(&state.db).await?;
599        Ok(WebCtx {
600            me,
601            flash: read_flash(&parts.headers),
602            unread,
603            branding,
604            here: parts
605                .uri
606                .path_and_query()
607                .map(|p| p.as_str().to_string())
608                .unwrap_or_else(|| "/".to_string()),
609        })
610    }
611}
612
613pub(crate) fn read_flash(headers: &HeaderMap) -> Option<Flash> {
614    auth::cookie_value(headers, auth::FLASH_COOKIE)
615        .filter(|v| !v.is_empty())
616        .and_then(|v| hex::decode(v).ok())
617        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
618}
619
620/// A revision's file list (JSON rows with their `format` id) split for the
621/// user's preferred CAD program (`cadformat::group_by_family`): the visible
622/// group first, then – only when it has files – the collapsed group of the
623/// native files of other CAD programs with their count and program names.
624/// Display only; the template shows the second group in a `<details>`.
625pub(crate) fn file_groups(preferred: &str, files: &[Value]) -> Value {
626    let formats: Vec<&str> = files
627        .iter()
628        .map(|f| f.get("format").and_then(Value::as_str).unwrap_or(""))
629        .collect();
630    let grouping = crate::cadformat::group_by_family(preferred, &formats);
631    let (mut shown, mut other) = (Vec::new(), Vec::new());
632    for (file, collapsed) in files.iter().zip(&grouping.collapsed) {
633        if *collapsed {
634            other.push(file.clone());
635        } else {
636            shown.push(file.clone());
637        }
638    }
639    let mut groups = vec![json!({ "files": shown, "collapsed": false })];
640    if !other.is_empty() {
641        groups.push(json!({
642            "files": other,
643            "collapsed": true,
644            "count": grouping.collapsed_count(),
645            "families": grouping.other_families.join(", "),
646        }));
647    }
648    json!(groups)
649}
650
651fn me_json(me: &Actor) -> Value {
652    json!({
653        "id": me.id,
654        "username": me.username,
655        "display_name": me.display_name,
656        "initials": domain::initials(&me.display_name, &me.username),
657        "avatar": domain::avatar_tint(&me.username),
658        "role": me.role,
659        "skin": me.skin,
660        "preferred_cad": me.preferred_cad,
661        "is_admin": me.is_admin(),
662        "can_edit": me.can_edit(),
663    })
664}
665
666/// The skin remembered in the browser (for pages without a session).
667pub(crate) fn skin_from_cookie(headers: &HeaderMap) -> &'static str {
668    domain::skin_or_default(&auth::cookie_value(headers, SKIN_COOKIE).unwrap_or_default())
669}
670
671/// `Set-Cookie` value remembering a skin for one year.
672pub(crate) fn skin_cookie(st: &AppState, skin: &str) -> Option<HeaderValue> {
673    let secure = if st.config.cookie_secure {
674        "; Secure"
675    } else {
676        ""
677    };
678    HeaderValue::from_str(&format!(
679        "{SKIN_COOKIE}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{secure}",
680        domain::skin_or_default(skin)
681    ))
682    .ok()
683}
684
685/// Renders a page for a signed-in user.
686pub(crate) fn render(
687    st: &AppState,
688    ctx: &WebCtx,
689    template: &str,
690    data: Value,
691) -> AppResult<Response> {
692    let mut data = data;
693    if let Value::Object(map) = &mut data {
694        map.insert("me".into(), me_json(&ctx.me));
695        map.insert("skin".into(), json!(ctx.me.skin));
696        map.insert("unread_notifications".into(), json!(ctx.unread));
697        map.insert("here".into(), json!(ctx.here));
698        map.insert("branding".into(), json!(ctx.branding));
699        map.insert(
700            "assistant_enabled".into(),
701            json!(crate::services::assistant::enabled(st)),
702        );
703    }
704    render_public(st, template, data, ctx.flash.clone())
705}
706
707/// Renders a page that does not require a signed-in user (login, errors).
708/// The skin is taken from `data.skin` (default: the house skin).
709pub(crate) fn render_public(
710    st: &AppState,
711    template: &str,
712    data: Value,
713    flash: Option<Flash>,
714) -> AppResult<Response> {
715    let mut data = data;
716    let has_flash = flash.is_some();
717    if let Value::Object(map) = &mut data {
718        map.insert("flash".into(), json!(flash));
719        map.insert("local_login".into(), json!(st.config.local_login));
720        let skin = map
721            .get("skin")
722            .and_then(Value::as_str)
723            .map(domain::skin_or_default)
724            .unwrap_or(domain::DEFAULT_SKIN);
725        map.insert("skin".into(), json!(skin));
726        map.insert(
727            "skin_scheme".into(),
728            json!(domain::skin_scheme(skin).as_str()),
729        );
730    }
731    let html = st.templates.get_template(template)?.render(&data)?;
732    let mut response = Html(html).into_response();
733    response
734        .headers_mut()
735        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
736    if has_flash {
737        if let Ok(v) = HeaderValue::from_str(&auth::clear_flash_cookie(&st.config)) {
738            response.headers_mut().append(header::SET_COOKIE, v);
739        }
740    }
741    Ok(response)
742}
743
744/// `303 See Other` to `to`.
745pub(crate) fn redirect(to: &str) -> Response {
746    Redirect::to(to).into_response()
747}
748
749/// Redirect that shows `flash` on the next page.
750pub(crate) fn redirect_flash(st: &AppState, to: &str, flash: Flash) -> Response {
751    let mut flash = flash;
752    if flash.message.chars().count() > 600 {
753        flash.message = flash.message.chars().take(600).collect::<String>() + "…";
754    }
755    let mut response = redirect(to);
756    let encoded = hex::encode(serde_json::to_vec(&flash).unwrap_or_default());
757    if let Ok(v) = HeaderValue::from_str(&auth::flash_cookie(&st.config, &encoded)) {
758        response.headers_mut().append(header::SET_COOKIE, v);
759    }
760    response
761}
762
763/// Errors caused by the user (as opposed to bugs or outages).
764pub(crate) fn is_user_error(e: &AppError) -> bool {
765    matches!(
766        e,
767        AppError::BadRequest(_)
768            | AppError::Conflict(_)
769            | AppError::Forbidden(_)
770            | AppError::NotFound(_)
771            | AppError::PayloadTooLarge(_)
772            | AppError::Unavailable(_)
773            | AppError::TooManyRequests { .. }
774    )
775}
776
777/// Finishes a form action: on success redirect to the target returned by
778/// `on_ok` with a success message; on user errors redirect to `back` with
779/// the error message; internal errors propagate to the error page.
780pub(crate) fn finish<T>(
781    st: &AppState,
782    result: AppResult<T>,
783    back: &str,
784    on_ok: impl FnOnce(T) -> (String, String),
785) -> AppResult<Response> {
786    match result {
787        Ok(value) => {
788            let (to, message) = on_ok(value);
789            Ok(redirect_flash(st, &to, Flash::success(message)))
790        }
791        Err(e) if is_user_error(&e) => {
792            Ok(redirect_flash(st, back, Flash::error(e.public_message())))
793        }
794        Err(e) => Err(e),
795    }
796}
797
798/// An `.xlsx` download with a sanitised file name (`Content-Disposition`).
799pub(crate) fn xlsx_response(bytes: Vec<u8>, filename: &str) -> Response {
800    let safe: String = filename
801        .chars()
802        .map(|c| {
803            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
804                c
805            } else {
806                '_'
807            }
808        })
809        .collect();
810    let mut response = bytes.into_response();
811    response.headers_mut().insert(
812        header::CONTENT_TYPE,
813        HeaderValue::from_static(
814            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
815        ),
816    );
817    if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{safe}\"")) {
818        response
819            .headers_mut()
820            .insert(header::CONTENT_DISPOSITION, value);
821    }
822    response
823        .headers_mut()
824        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
825    response
826}
827
828/// Parses an optional UUID form/query value; empty strings mean `None`.
829pub(crate) fn parse_opt_uuid(field: &str, value: Option<&str>) -> AppResult<Option<Uuid>> {
830    match value.map(str::trim).filter(|v| !v.is_empty()) {
831        None => Ok(None),
832        Some(v) => Uuid::parse_str(v)
833            .map(Some)
834            .map_err(|_| AppError::BadRequest(format!("{field}: invalid identifier."))),
835    }
836}
837
838/// Builds the pagination block used by list templates.
839pub(crate) fn pagination(page: i64, total: i64, per_page: i64) -> Value {
840    let pages = ((total + per_page - 1) / per_page).max(1);
841    let page = page.clamp(1, pages);
842    json!({
843        "page": page,
844        "pages": pages,
845        "total": total,
846        "prev": if page > 1 { Some(page - 1) } else { None },
847        "next": if page < pages { Some(page + 1) } else { None },
848    })
849}
850
851// ---------------------------------------------------------------------------
852// Middleware
853// ---------------------------------------------------------------------------
854
855/// Replaces JSON error bodies by HTML pages and sends unauthenticated users
856/// to the login page.
857async fn html_errors(State(st): State<AppState>, req: Request, next: Next) -> Response {
858    let target = req
859        .uri()
860        .path_and_query()
861        .map(|p| p.as_str().to_string())
862        .unwrap_or_else(|| "/".into());
863    let is_get = req.method() == Method::GET;
864    let skin = skin_from_cookie(req.headers());
865    let response = next.run(req).await;
866    // The viewer fetches preview meshes itself and shows the JSON error message.
867    let path = target.split('?').next().unwrap_or("");
868    if path.ends_with("/mesh") {
869        return response;
870    }
871    // So does the chunked upload of app.js – a 401 too, it then asks to sign in.
872    if path == "/uploads" || path.starts_with("/uploads/") || path.starts_with("/assistant/") {
873        return response;
874    }
875    let Some(info) = response.extensions().get::<ErrorInfo>().cloned() else {
876        return response;
877    };
878    if info.status == StatusCode::UNAUTHORIZED {
879        let to = if is_get && target != "/" {
880            format!("/login?next={}", util::pct_encode(&target))
881        } else {
882            "/login".to_string()
883        };
884        return redirect(&to);
885    }
886    // the replaced logos, as on the other pages; not for server errors, whose
887    // cause may be the database itself (then the built-in logos are shown)
888    let branding = if info.status.is_server_error() {
889        crate::services::branding::Branding::default()
890    } else {
891        crate::services::branding::current(&st.db)
892            .await
893            .unwrap_or_default()
894    };
895    let data = json!({
896        "status": info.status.as_u16(),
897        "reason": info.status.canonical_reason().unwrap_or("Error"),
898        "message": info.message,
899        "skin": skin,
900        "branding": branding,
901    });
902    match render_public(&st, "error.html", data, None) {
903        Ok(mut page) => {
904            *page.status_mut() = info.status;
905            page
906        }
907        Err(_) => (info.status, info.message).into_response(),
908    }
909}
910
911/// Rejects cross-site form submissions (defence in depth on top of
912/// `SameSite=Lax` cookies): the `Origin` (or `Referer`) of every
913/// non-GET request must match the host the browser talked to.
914async fn csrf_guard(State(st): State<AppState>, req: Request, next: Next) -> Response {
915    let safe = matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS);
916    if !safe && !same_origin(req.headers(), &st.config.public_url) {
917        tracing::warn!(
918            origin = ?req.headers().get(header::ORIGIN),
919            path = %req.uri().path(),
920            "blocked cross-site request"
921        );
922        return AppError::Forbidden(
923            "Cross-site request blocked. Reload the page and try again.".into(),
924        )
925        .into_response();
926    }
927    next.run(req).await
928}
929
930fn authority(url: &str) -> Option<String> {
931    let rest = url.split_once("://")?.1;
932    let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
933    (!host.is_empty()).then(|| host.to_ascii_lowercase())
934}
935
936/// Whether the request originates from a page served by this instance.
937pub(crate) fn same_origin(headers: &HeaderMap, public_url: &str) -> bool {
938    let header_str = |name: header::HeaderName| headers.get(name).and_then(|v| v.to_str().ok());
939    let source = match header_str(header::ORIGIN) {
940        Some("null") => return false,
941        Some(origin) => authority(origin),
942        None => match header_str(header::REFERER) {
943            Some(referer) => authority(referer),
944            // Neither header: browsers send one on every cross-site POST, so a
945            // request without both is not a browser form post (F4).
946            None => return false,
947        },
948    };
949    let Some(source) = source else { return false };
950    let mut allowed: Vec<String> = Vec::new();
951    if let Some(host) = header_str(header::HOST) {
952        allowed.push(host.to_ascii_lowercase());
953    }
954    if let Some(fwd) = header_str(header::HeaderName::from_static("x-forwarded-host")) {
955        if let Some(first) = fwd.split(',').next() {
956            allowed.push(first.trim().to_ascii_lowercase());
957        }
958    }
959    if let Some(public) = authority(public_url) {
960        allowed.push(public);
961    }
962    allowed.contains(&source)
963}
964
965// ---------------------------------------------------------------------------
966// Static assets & fallback
967// ---------------------------------------------------------------------------
968
969/// Embedded stylesheets, scripts, logos and fonts (see `assets`).
970async fn static_asset(uri: Uri, headers: HeaderMap) -> Response {
971    match assets::resolve(uri.path(), uri.query()) {
972        Some((asset, current)) => assets::respond(asset, current, &headers),
973        None => not_found().await.into_response(),
974    }
975}
976
977/// The TeamCraft icon is the favicon.
978async fn favicon(uri: Uri, headers: HeaderMap) -> Response {
979    match assets::resolve(assets::FAVICON, uri.query()) {
980        Some((asset, current)) => assets::respond(asset, current, &headers),
981        None => not_found().await.into_response(),
982    }
983}
984
985async fn not_found() -> AppError {
986    AppError::NotFound("The page you are looking for does not exist.".into())
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap {
994        let mut h = HeaderMap::new();
995        for (k, v) in pairs {
996            h.insert(
997                header::HeaderName::from_static(k),
998                HeaderValue::from_static(v),
999            );
1000        }
1001        h
1002    }
1003
1004    #[test]
1005    fn origin_checks() {
1006        let public = "https://plm.example.org";
1007        assert!(same_origin(
1008            &headers(&[("origin", "https://plm.example.org")]),
1009            public
1010        ));
1011        assert!(same_origin(
1012            &headers(&[
1013                ("origin", "http://localhost:8080"),
1014                ("host", "localhost:8080")
1015            ]),
1016            public
1017        ));
1018        assert!(same_origin(
1019            &headers(&[
1020                ("origin", "https://proxy.example.org"),
1021                ("x-forwarded-host", "proxy.example.org")
1022            ]),
1023            public
1024        ));
1025        assert!(!same_origin(
1026            &headers(&[
1027                ("origin", "https://evil.example"),
1028                ("host", "plm.example.org")
1029            ]),
1030            public
1031        ));
1032        assert!(!same_origin(&headers(&[("origin", "null")]), public));
1033        assert!(same_origin(
1034            &headers(&[("referer", "https://plm.example.org/items?q=1")]),
1035            public
1036        ));
1037        assert!(!same_origin(
1038            &headers(&[("referer", "https://evil.example/x")]),
1039            public
1040        ));
1041        // F4: neither header present is refused.
1042        assert!(!same_origin(&headers(&[]), public));
1043    }
1044
1045    #[test]
1046    fn pagination_block() {
1047        let p = pagination(2, 120, 50);
1048        assert_eq!(p["pages"], 3);
1049        assert_eq!(p["prev"], 1);
1050        assert_eq!(p["next"], 3);
1051        let single = pagination(1, 0, 50);
1052        assert_eq!(single["pages"], 1);
1053        assert!(single["next"].is_null());
1054    }
1055
1056    #[test]
1057    fn file_groups_put_other_cad_programs_into_a_second_group() {
1058        let files = vec![
1059            json!({ "filename": "bracket.prt", "format": "nx-part" }),
1060            json!({ "filename": "bracket.sldprt", "format": "solidworks-part" }),
1061            json!({ "filename": "bracket.step", "format": "step" }),
1062            json!({ "filename": "bracket.ipt", "format": "inventor-part" }),
1063        ];
1064        let names = |g: &Value| -> Vec<String> {
1065            g["files"]
1066                .as_array()
1067                .unwrap()
1068                .iter()
1069                .map(|f| f["filename"].as_str().unwrap().to_string())
1070                .collect()
1071        };
1072        let groups = file_groups("nx", &files);
1073        assert_eq!(groups.as_array().map(Vec::len), Some(2));
1074        assert_eq!(names(&groups[0]), ["bracket.prt", "bracket.step"]);
1075        assert_eq!(groups[0]["collapsed"], false);
1076        assert_eq!(names(&groups[1]), ["bracket.sldprt", "bracket.ipt"]);
1077        assert_eq!(groups[1]["collapsed"], true);
1078        assert_eq!(groups[1]["count"], 2);
1079        assert_eq!(groups[1]["families"], "SolidWorks, Inventor");
1080
1081        // no preference, or no file of the preferred program: one group with all
1082        for preferred in ["", "creo"] {
1083            let groups = file_groups(preferred, &files);
1084            assert_eq!(groups.as_array().map(Vec::len), Some(1), "{preferred}");
1085            assert_eq!(names(&groups[0]).len(), 4);
1086        }
1087        // an empty list still gives one (empty) group
1088        assert_eq!(file_groups("nx", &[]).as_array().map(Vec::len), Some(1));
1089    }
1090}