1mod account;
11mod admin;
12pub(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
39pub(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
70const SKIN_COOKIE: &str = "tc_skin";
72
73pub(crate) const PER_PAGE: i64 = 50;
75
76pub fn router(state: &AppState) -> Router<AppState> {
77 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 .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 .route(
104 "/items",
105 get(items::list)
106 .post(items::create)
107 .layer(DefaultBodyLimit::max(upload_limit)),
108 )
109 .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 .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 .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 .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 .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 .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 .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 .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 .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 .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 .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 .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 .route(
492 "/desktop/authorize",
493 get(desktop::authorize_page).post(desktop::authorize_submit),
494 )
495 .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
508pub(crate) const SCRIPT_HEADER: &str = "x-teamcraft-script";
511
512async 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#[derive(Clone, Debug, Serialize, Deserialize)]
547pub struct Flash {
548 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
576pub struct WebCtx {
579 pub me: Actor,
580 pub flash: Option<Flash>,
581 pub unread: i64,
582 pub branding: crate::services::branding::Branding,
584 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
620pub(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
666pub(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
671pub(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
685pub(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
707pub(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
744pub(crate) fn redirect(to: &str) -> Response {
746 Redirect::to(to).into_response()
747}
748
749pub(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
763pub(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
777pub(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
798pub(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
828pub(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
838pub(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
851async 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 let path = target.split('?').next().unwrap_or("");
868 if path.ends_with("/mesh") {
869 return response;
870 }
871 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 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
911async 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
936pub(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 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
965async 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
977async 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 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 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 assert_eq!(file_groups("nx", &[]).as_array().map(Vec::len), Some(1));
1089 }
1090}