1use std::sync::Arc;
5
6use toasty::{Db, schema::Model};
7use topcoat::{
8 Result,
9 asset::AssetConfig,
10 context::Cx,
11 cookie::RouterBuilderCookieExt,
12 router::{
13 Body, LayoutFn, Path, RouteFn, RouteFuture, RouterBuilder, RouterBuilderDirectoryExt,
14 error::redirect,
15 },
16 runtime::{PrefetchMode, RouterBuilderRuntimeExt, RuntimeSetup},
17 session::{RouterBuilderSessionExt, SessionConfig},
18};
19
20use super::{
21 Panel, Root,
22 forms::MAX_FORM_BYTES,
23 headers,
24 register::Registry,
25 search::{ShardPanel, TABLE_RELATION_SEARCH_PATH, TABLE_SEARCH_PATH},
26 state::{PanelState, Panels, current, under_prefix},
27};
28use crate::{
29 DeclarationError, DeclarationErrorKind, MountError, Site,
30 auth::{PanelGate, RuntimeGate, SESSION_LIFETIME},
31 declaration::segment_fault,
32 form::RecordForm,
33 policy::Ability,
34 resource::{MountScope, Mounted, Mounts, Resource, require_mounted},
35 tenancy::TenantSource,
36 topcoat_compat::RUNTIME_PREFIX,
37};
38
39pub trait RouterBuilderPanelExt: Sized {
53 fn panel(self, panel: Panel) -> Result<Self>;
55}
56
57impl RouterBuilderPanelExt for RouterBuilder {
58 fn panel(self, panel: Panel) -> Result<Self> {
59 panel.mount(self)
60 }
61}
62
63impl Panel {
64 fn mount(self, mut builder: RouterBuilder) -> Result<RouterBuilder> {
65 let db = builder.get_app_context::<Db>().cloned();
66 let mut registry = Registry::new(
67 self.prefix.clone(),
68 db.as_ref().map(|db| db.schema().clone()),
69 );
70 let Panel {
71 prefix,
72 shell_assets,
73 brand,
74 dark_mode,
75 layout,
76 registrations,
77 frame_ancestors,
78 configuration_errors,
79 uploads,
80 served_dirs,
81 login_hint,
82 auth,
83 } = self;
84 for registration in registrations {
85 registration.register(&mut registry);
86 }
87 registry.link_relations();
88 let mounts = Arc::new(std::mem::take(&mut registry.mounts));
89 let mut errors = configuration_errors;
90 errors.append(&mut registry.errors);
91 errors.extend(mount_errors(
92 &builder,
93 &prefix,
94 shell_assets.is_some(),
95 &served_dirs,
96 ));
97 match &db {
98 Some(db) => {
99 let cx = validation_cx(db, &mounts);
100 for registered in ®istry.resources {
101 (registered.check)(&cx, &mut errors);
102 }
103 if let Err(kind) = crate::auth::check_models_registered(db, &auth) {
104 errors.push(DeclarationError::panel(kind));
105 }
106 }
107 None => errors.push(DeclarationError::panel(DeclarationErrorKind::MissingDb)),
108 }
109 if !errors.is_empty() {
110 return Err(MountError::new(&prefix, errors).into());
111 }
112 if builder.get_app_context::<Panels>().is_none() {
113 builder = install_shared(builder);
114 }
115 let Registry {
116 urls,
117 nav_items,
118 pages,
119 routes,
120 root,
121 search,
122 relation_search,
123 children,
124 ..
125 } = registry;
126 let root_redirect = match root {
127 Some(Root::Redirect(target)) => Some(target),
128 Some(Root::Home) | None => None,
129 };
130 let served_paths = served_dirs.iter().map(|(path, _)| path.clone()).collect();
131 let state = Arc::new(PanelState {
132 prefix: prefix.clone(),
133 nav_items,
134 brand,
135 dark_mode: dark_mode.unwrap_or(false),
136 shell_assets,
137 search,
138 relations: relation_search,
139 children,
140 mounts,
141 root_redirect: root_redirect.clone(),
142 auth,
143 login_hint,
144 uploads,
145 served_paths,
146 urls,
147 });
148 let prefix_path = route_path(&prefix);
149 builder =
150 builder.layer(topcoat::router::BodyLimit::max(MAX_FORM_BYTES).at(prefix_path.clone()));
151 if let Some(directive) = frame_ancestors {
152 builder = builder.layer(headers::FrameAncestors::new(directive, prefix.clone()));
153 }
154 builder = builder.layer(PanelGate::new(Arc::clone(&state)));
156 if state.gates() {
158 let login_path = route_path(&format!("{prefix}/login"));
159 let logout_path = route_path(&format!("{prefix}/logout"));
160 let tenant_path = route_path(&format!("{prefix}/tenant"));
161 builder = builder
162 .layer(
163 topcoat::router::BodyLimit::max(crate::auth::MAX_LOGIN_BYTES)
164 .at(login_path.clone()),
165 )
166 .route(RouteFn::new(
167 http::Method::GET,
168 login_path.clone(),
169 crate::auth::login_page,
170 ))
171 .route(RouteFn::new(
172 http::Method::POST,
173 login_path,
174 crate::auth::login_post,
175 ))
176 .route(RouteFn::new(
177 http::Method::POST,
178 logout_path,
179 crate::auth::logout_post,
180 ))
181 .route(RouteFn::new(
182 http::Method::POST,
183 tenant_path,
184 crate::auth::tenant_post,
185 ));
186 }
187 builder = builder.layout(LayoutFn::new(
188 prefix_path.clone(),
189 layout.unwrap_or(Panel::layout_shell),
190 ));
191 for (path, dir) in served_dirs {
192 builder = builder
193 .layer(headers::ServedFileHeaders::new(&path))
194 .serve_dir(route_path(&path), dir);
195 }
196 for page in pages {
197 builder = builder.page(page);
198 }
199 for route in routes {
200 builder = builder.route(route);
201 }
202 if root_redirect.is_some() {
203 builder = builder.route(RouteFn::new(
204 http::Method::GET,
205 prefix_path,
206 panel_root_redirect,
207 ));
208 }
209 builder
210 .get_app_context_mut::<Panels>()
211 .expect("the shared panel state is installed above")
212 .0
213 .push(state);
214 Ok(builder)
215 }
216}
217
218fn mount_errors(
220 builder: &RouterBuilder,
221 prefix: &str,
222 shell_assets: bool,
223 served_dirs: &[(String, std::path::PathBuf)],
224) -> Vec<DeclarationError> {
225 let mut errors = Vec::new();
226 let mut refuse = |kind| errors.push(DeclarationError::panel(kind));
227 if shell_assets && builder.get_app_context::<AssetConfig>().is_none() {
228 refuse(DeclarationErrorKind::ShellAssetsWithoutBundle);
229 }
230 if under_prefix(RUNTIME_PREFIX, prefix) || under_prefix(prefix, RUNTIME_PREFIX) {
231 refuse(DeclarationErrorKind::PrefixOverlapsRuntime {
232 prefix: prefix.to_string(),
233 });
234 }
235 for (index, (path, _)) in served_dirs.iter().enumerate() {
236 let root = served_root(path);
237 if served_dirs[..index]
238 .iter()
239 .any(|(seen, _)| served_root(seen) == root)
240 {
241 refuse(DeclarationErrorKind::ServeDirTwice { path: path.clone() });
242 }
243 }
244 if let Some(panels) = builder.get_app_context::<Panels>() {
245 for other in &panels.0 {
246 for (path, _) in served_dirs {
247 if other
248 .served_paths
249 .iter()
250 .any(|served| served_root(served) == served_root(path))
251 {
252 refuse(DeclarationErrorKind::ServeDirTaken {
253 path: path.clone(),
254 panel: other.prefix.clone(),
255 });
256 }
257 }
258 if under_prefix(&other.prefix, prefix) || under_prefix(prefix, &other.prefix) {
259 refuse(DeclarationErrorKind::PrefixOverlapsPanel {
260 other: other.prefix.clone(),
261 });
262 }
263 }
264 }
265 errors
266}
267
268fn install_shared(mut builder: RouterBuilder) -> RouterBuilder {
270 builder = builder.cookies();
271 if builder.get_app_context::<SessionConfig>().is_none() {
272 builder = builder.sessions(SessionConfig::builder().lifetime(SESSION_LIFETIME).build());
273 }
274 builder = builder
275 .layer(RuntimeGate::new())
276 .layer(ShardPanel::new(TABLE_SEARCH_PATH, 0))
277 .layer(ShardPanel::new(TABLE_RELATION_SEARCH_PATH, 1))
278 .app_context(Panels::default())
279 .app_context(MountScope(|cx| current(cx).map(|panel| &*panel.mounts)))
280 .app_context(TenantSource(crate::auth::session_tenant));
281 if builder.get_app_context::<RuntimeSetup>().is_none() {
284 builder = builder.runtime();
285 }
286 if builder.get_app_context::<PrefetchMode>().is_none() {
287 builder = builder.prefetch(PrefetchMode::Never);
288 }
289 builder
290}
291
292pub(crate) fn panel_root_redirect(cx: &Cx, _body: Body) -> RouteFuture<'_> {
295 Box::pin(async move {
296 crate::auth::guard(cx)?;
298 let target = current(cx)
299 .and_then(|panel| panel.root_redirect.clone())
300 .ok_or_else(topcoat::router::error::not_found)?;
301 Err(redirect(target).into())
302 })
303}
304
305pub(super) fn is_directory_pattern(path: &str) -> bool {
307 Path::from_str(path)
308 .ok()
309 .and_then(|parsed| parsed.segments().next_back())
310 .is_some_and(|segment| segment.as_catch_all().is_some())
311}
312
313pub(super) fn validate_route_segment(
316 item: &'static str,
317 segment: &str,
318) -> Result<(), DeclarationErrorKind> {
319 match segment_fault(segment) {
320 Some(fault) => Err(DeclarationErrorKind::InvalidSegment {
321 item,
322 segment: segment.to_string(),
323 fault,
324 }),
325 None => Ok(()),
326 }
327}
328
329pub(super) type ResourceCheck = fn(&Cx, &mut Vec<DeclarationError>);
332
333pub(super) fn check_resource<R: Resource>(cx: &Cx, errors: &mut Vec<DeclarationError>) {
335 let Ok(declared) = require_mounted::<R>(cx) else {
336 return;
337 };
338 let tenancy = &declared.tenancy;
339 if let Some(Err(kind)) = tenancy.column_field() {
340 errors.push(DeclarationError::of::<R>(Site::Tenancy, kind));
341 }
342 if tenancy.via_is_single() == Some(true) {
343 errors.push(DeclarationError::of::<R>(
344 Site::Tenancy,
345 DeclarationErrorKind::TenancyViaOwnColumn,
346 ));
347 }
348 let form_errors = declared.form.declaration_errors();
349 let form_is_sound = form_errors.is_empty();
350 let view_errors = if declared.has_own_view() {
351 declared.view().declaration_errors()
352 } else {
353 Vec::new()
354 };
355 for (site, kinds) in [
356 (Site::Table, declared.table.declaration_errors()),
357 (Site::Form, form_errors),
358 (Site::View, view_errors),
359 ] {
360 errors.extend(
361 kinds
362 .into_iter()
363 .map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
364 );
365 }
366 check_actions(&declared, errors);
367 check_form_declaration(cx, &declared, form_is_sound, errors);
368}
369
370fn check_actions<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
374 let mut seen = std::collections::HashSet::new();
375 for action in declared.actions.entries() {
376 if !seen.insert(action.name) {
377 errors.push(DeclarationError::of::<R>(
378 Site::Registration,
379 DeclarationErrorKind::DuplicateAction { name: action.name },
380 ));
381 }
382 }
383}
384
385fn form_error<R: Resource>(kind: DeclarationErrorKind) -> DeclarationError {
387 DeclarationError::of::<R>(Site::Form, kind)
388}
389
390fn check_form_declaration<R: Resource>(
392 cx: &Cx,
393 declared: &Mounted<R>,
394 form_is_sound: bool,
395 errors: &mut Vec<DeclarationError>,
396) {
397 if <R::Form as RecordForm>::HAS_FORM {
398 if declared.form.is_empty() && !declared.fields.is_empty() {
399 errors.push(form_error::<R>(DeclarationErrorKind::EmptyFormOverride));
400 } else if form_is_sound {
401 check_form_inner(cx, declared, errors);
403 }
404 } else if !declared.form.is_empty() {
405 errors.push(form_error::<R>(DeclarationErrorKind::FormWithoutRecordForm));
406 } else if declared.can(cx, Ability::Create) {
407 errors.push(form_error::<R>(DeclarationErrorKind::CreateWithoutForm));
408 }
409}
410
411fn check_form_inner<R: Resource>(
412 cx: &Cx,
413 declared: &Mounted<R>,
414 errors: &mut Vec<DeclarationError>,
415) {
416 let (fields, form) = (declared.fields.as_slice(), &*declared.form);
417 let controls = form.controls();
418 for control in &controls {
419 let claims = fields
420 .iter()
421 .filter(|field| field.keys.contains(&control.name))
422 .count();
423 let control = control.name.clone();
424 match claims {
425 0 => errors.push(form_error::<R>(DeclarationErrorKind::UnboundControl {
426 control,
427 })),
428 1 => {}
429 _ => errors.push(form_error::<R>(DeclarationErrorKind::ControlBoundTwice {
430 control,
431 })),
432 }
433 }
434 for field in fields {
435 for key in &field.keys {
436 if !controls.iter().any(|control| &control.name == key) {
437 errors.push(form_error::<R>(DeclarationErrorKind::MissingControl {
438 field: field.name.to_string(),
439 key: key.clone(),
440 }));
441 }
442 }
443 if field.answers_blank {
445 continue;
446 }
447 if let Some(control) = controls.iter().find(|control| {
448 field.keys.contains(&control.name)
449 && control.needs_answer()
450 && (!control.required || control.in_repeater)
451 }) {
452 errors.push(form_error::<R>(DeclarationErrorKind::NoBlankAnswer {
453 control: control.name.clone(),
454 field: field.name.to_string(),
455 in_repeater: control.in_repeater,
456 }));
457 }
458 }
459 if let Some(column) = tenant_column(declared)
461 && let Some(field) = fields.iter().find(|field| field.keys.contains(&column))
462 {
463 errors.push(form_error::<R>(
464 DeclarationErrorKind::FormClaimsTenantColumn {
465 field: field.name.to_string(),
466 column,
467 },
468 ));
469 }
470 for field in form.fields().filter(|field| {
471 field
472 .as_choice()
473 .is_some_and(|choice| choice.has_composite_source())
474 }) {
475 errors.push(form_error::<R>(DeclarationErrorKind::CompositeKeyChoice {
476 field: field.name().to_string(),
477 }));
478 }
479 for field in form.fields() {
480 if let Some(source) = field
481 .as_choice()
482 .and_then(|choice| choice.unavailable_source(cx))
483 {
484 errors.push(form_error::<R>(
485 DeclarationErrorKind::UnregisteredOptionSource {
486 field: field.name().to_string(),
487 source,
488 },
489 ));
490 }
491 }
492 if declared.tenancy.via_is_single() == Some(false) {
493 check_via_foreign_keys(cx, declared, errors);
494 }
495 if declared.can(cx, Ability::Create) {
496 check_create_columns(declared, errors);
497 }
498 let model = R::Model::schema();
499 let root = model.as_root_unwrap();
500 for field in form.fields().filter(|field| field.is_unique()) {
501 let name = field.name();
502 let backed = root
503 .fields
504 .iter()
505 .filter(|field| field.name.app_unwrap() == name)
506 .any(|field| crate::schema::lens_field_unique(field, root));
507 if !backed {
508 errors.push(form_error::<R>(DeclarationErrorKind::UniqueWithoutIndex {
509 field: name.to_string(),
510 }));
511 }
512 }
513}
514
515fn tenant_column<R: Resource>(declared: &Mounted<R>) -> Option<String> {
517 declared
518 .tenancy
519 .column_field()
520 .and_then(Result::ok)
521 .map(|field| field.name.clone())
522}
523
524fn check_create_columns<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
526 let (fields, create_columns) = (&declared.fields, &declared.create_columns);
527 let prefilled = crate::form::prefilled_fields::<R::Model>();
528 let tenant = tenant_column(declared);
529 if let Some(column) = &tenant
530 && create_columns.contains(&column.as_str())
531 {
532 errors.push(DeclarationError::of::<R>(
533 Site::Registration,
534 DeclarationErrorKind::CreateColumnsNameTenant {
535 column: column.clone(),
536 },
537 ));
538 }
539 let model = R::Model::schema();
540 let root = model.as_root_unwrap();
541 for &column in create_columns {
542 if !root
543 .fields
544 .iter()
545 .any(|field| field.name.app.as_deref() == Some(column))
546 {
547 errors.push(DeclarationError::of::<R>(
548 Site::Registration,
549 DeclarationErrorKind::UnknownCreateColumn { column },
550 ));
551 }
552 }
553 for (index, field) in root.fields.iter().enumerate() {
554 let Some(name) = field.name.app.as_deref() else {
555 continue;
556 };
557 let filled = field.nullable()
558 || field.is_relation()
559 || prefilled.get(index).copied().unwrap_or(false)
560 || tenant.as_deref() == Some(name)
561 || fields.iter().any(|claim| claim.name == name)
562 || create_columns.contains(&name);
563 if !filled {
564 errors.push(form_error::<R>(DeclarationErrorKind::UnwrittenColumn {
565 column: name.to_string(),
566 }));
567 }
568 }
569}
570
571fn validation_cx(db: &Db, mounts: &Arc<Mounts>) -> Cx {
574 let mut app_context = topcoat::context::AppContext::new();
575 app_context.insert(db.clone());
576 app_context.insert(Arc::clone(mounts));
577 app_context.insert(MountScope(|cx| {
578 topcoat::context::try_app_context::<Arc<Mounts>>(cx).map(|mounts| &**mounts)
579 }));
580 Cx::new(Arc::new(app_context))
581}
582
583fn check_via_foreign_keys<R: Resource>(
587 cx: &Cx,
588 declared: &Mounted<R>,
589 errors: &mut Vec<DeclarationError>,
590) {
591 let form = &declared.form;
592 let Some((relation, parent, keys)) = via_relation(declared) else {
593 errors.push(DeclarationError::of::<R>(
594 Site::Tenancy,
595 DeclarationErrorKind::TenancyViaWithoutBelongsTo,
596 ));
597 return;
598 };
599 let unguarded: Vec<String> = keys
600 .into_iter()
601 .filter(|key| {
602 !form.fields().any(|field| {
603 field.name() == key
604 && field
605 .as_choice()
606 .and_then(|choice| choice.tenant_scoped_model(cx))
607 == Some(parent)
608 })
609 })
610 .collect();
611 if !unguarded.is_empty() {
612 errors.push(form_error::<R>(DeclarationErrorKind::UnguardedForeignKey {
613 relation,
614 keys: unguarded,
615 }));
616 }
617}
618
619fn via_relation<R: Resource>(
622 declared: &Mounted<R>,
623) -> Option<(String, toasty::schema::app::ModelId, Vec<String>)> {
624 let hop = declared.tenancy.via_hop()?;
625 let model = R::Model::schema();
626 let root = model.as_root()?;
627 let field = root.fields.get(hop)?;
628 let toasty::schema::app::FieldTy::BelongsTo(relation) = &field.ty else {
629 return None;
630 };
631 let keys = relation
632 .foreign_key
633 .fields
634 .iter()
635 .filter_map(|key| root.fields.get(key.source.index))
636 .map(|field| field.name.app_unwrap().to_string())
637 .collect::<Vec<_>>();
638 (!keys.is_empty()).then(|| (field.name.app_unwrap().to_string(), relation.target, keys))
639}
640
641fn served_root(path: &str) -> &str {
644 path.rsplit_once("{*").map_or(path, |(root, _)| root)
645}
646
647pub(crate) fn route_path(path: &str) -> topcoat::router::PathBuf {
649 Path::from_str(path)
650 .expect("panel route paths are well-formed")
651 .to_owned()
652}
653
654#[cfg(test)]
655mod tests;