1use std::{collections::HashMap, sync::Arc};
5
6use toasty::Db;
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 state::{PanelState, Panels, current, under_prefix},
26};
27use crate::{
28 ActionInputFault, DeclarationError, DeclarationErrorKind, ManyToManyFault, MountError, Page,
29 Site,
30 auth::{PanelGate, RuntimeGate, SESSION_LIFETIME},
31 declaration::segment_fault,
32 form::RecordForm,
33 policy::Ability,
34 resource::{InputSpec, MountScope, Mounted, Mounts, Resource, require_mounted},
35 tenancy::TenantSource,
36 toasty_compat::{
37 join::JoinTable,
38 model::{self, AppSchema},
39 },
40 topcoat_compat::RUNTIME_PREFIX,
41};
42
43pub trait RouterBuilderPanelExt: Sized {
80 fn panel(self, panel: Panel) -> Result<Self>;
82
83 fn panel_handle(&self, prefix: &str) -> Option<PanelHandle>;
87}
88
89impl RouterBuilderPanelExt for RouterBuilder {
90 fn panel(self, panel: Panel) -> Result<Self> {
91 panel.mount(self)
92 }
93
94 fn panel_handle(&self, prefix: &str) -> Option<PanelHandle> {
95 let panel = self
96 .get_app_context::<Panels>()?
97 .by_prefix(&super::normalize_prefix(prefix))?;
98 Some(PanelHandle {
99 db: self.get_app_context::<Db>()?.clone(),
100 mounts: Arc::clone(&panel.mounts),
101 })
102 }
103}
104
105#[derive(Clone)]
131pub struct PanelHandle {
132 db: Db,
133 mounts: Arc<Mounts>,
134}
135
136impl PanelHandle {
137 pub fn context(&self) -> Cx {
144 validation_cx(&self.db, &self.mounts)
145 }
146}
147
148impl Panel {
149 pub fn context(self, db: &Db) -> Result<Cx> {
163 let Panel {
164 prefix,
165 registrations,
166 configuration_errors,
167 uploads,
168 ..
169 } = self;
170 let mut registry = Registry::new(prefix.clone(), Some(AppSchema::of_db(db)));
171 let (mounts, mut errors) = registry.register_all(registrations, configuration_errors);
172 let cx = registry.check_all(db, &mounts, uploads.is_some(), &mut errors);
173 if !errors.is_empty() {
174 return Err(MountError::new(&prefix, errors).into());
175 }
176 Ok(cx)
177 }
178
179 fn mount(self, mut builder: RouterBuilder) -> Result<RouterBuilder> {
180 let db = builder.get_app_context::<Db>().cloned();
181 let mut registry = Registry::new(self.prefix.clone(), db.as_ref().map(AppSchema::of_db));
182 let Panel {
183 prefix,
184 shell_assets,
185 brand,
186 dark_mode,
187 layout,
188 registrations,
189 frame_ancestors,
190 configuration_errors,
191 uploads,
192 served_dirs,
193 login_hint,
194 auth,
195 } = self;
196 let (mounts, mut errors) = registry.register_all(registrations, configuration_errors);
197 errors.extend(mount_errors(
198 &builder,
199 &prefix,
200 shell_assets.is_some(),
201 &served_dirs,
202 ));
203 match &db {
204 Some(db) => {
205 registry.check_all(db, &mounts, uploads.is_some(), &mut errors);
206 if let Err(kind) = crate::auth::check_models_registered(db, &auth) {
207 errors.push(DeclarationError::panel(kind));
208 }
209 errors.extend(
210 crate::auth::check_registration(&auth)
211 .into_iter()
212 .map(DeclarationError::panel),
213 );
214 }
215 None => errors.push(DeclarationError::panel(DeclarationErrorKind::MissingDb)),
216 }
217 if !errors.is_empty() {
218 return Err(MountError::new(&prefix, errors).into());
219 }
220 if builder.get_app_context::<Panels>().is_none() {
221 builder = install_shared(builder);
222 }
223 let Registry {
224 urls,
225 nav_items,
226 pages,
227 routes,
228 root,
229 children,
230 ..
231 } = registry;
232 let root_redirect = match root {
233 Some(Root::Redirect(target)) => Some(target),
234 Some(Root::Home) | None => None,
235 };
236 let served_paths = served_dirs.iter().map(|(path, _)| path.clone()).collect();
237 let state = Arc::new(PanelState {
238 prefix: prefix.clone(),
239 nav_items,
240 brand,
241 dark_mode: dark_mode.unwrap_or(false),
242 shell_assets,
243 children,
244 mounts,
245 root_redirect: root_redirect.clone(),
246 auth,
247 login_hint,
248 uploads,
249 served_paths,
250 urls,
251 });
252 let prefix_path = route_path(&prefix);
253 builder =
254 builder.layer(topcoat::router::BodyLimit::max(MAX_FORM_BYTES).at(prefix_path.clone()));
255 if let Some(directive) = frame_ancestors {
256 builder = builder.layer(headers::FrameAncestors::new(directive, prefix.clone()));
257 }
258 builder = builder.layer(PanelGate::new(Arc::clone(&state)));
260 if state.gates() {
262 let login_path = route_path(&format!("{prefix}/login"));
263 let logout_path = route_path(&format!("{prefix}/logout"));
264 let tenant_path = route_path(&format!("{prefix}/tenant"));
265 builder = builder
266 .layer(
267 topcoat::router::BodyLimit::max(crate::auth::MAX_LOGIN_BYTES)
268 .at(login_path.clone()),
269 )
270 .route(RouteFn::new(
271 http::Method::GET,
272 login_path.clone(),
273 crate::auth::login_page,
274 ))
275 .route(RouteFn::new(
276 http::Method::POST,
277 login_path,
278 crate::auth::login_post,
279 ))
280 .route(RouteFn::new(
281 http::Method::POST,
282 logout_path,
283 crate::auth::logout_post,
284 ))
285 .route(RouteFn::new(
286 http::Method::POST,
287 tenant_path,
288 crate::auth::tenant_post,
289 ));
290 }
291 if state.auth.registrar().is_some() {
292 let register_path = route_path(&format!("{prefix}/register"));
293 builder = builder
294 .layer(
295 topcoat::router::BodyLimit::max(crate::auth::MAX_LOGIN_BYTES)
296 .at(register_path.clone()),
297 )
298 .route(RouteFn::new(
299 http::Method::GET,
300 register_path.clone(),
301 crate::auth::register_page,
302 ))
303 .route(RouteFn::new(
304 http::Method::POST,
305 register_path,
306 crate::auth::register_post,
307 ));
308 }
309 builder = builder.layout(LayoutFn::new(
310 prefix_path.clone(),
311 layout.unwrap_or(Panel::layout_shell),
312 ));
313 for (path, dir) in served_dirs {
314 builder = builder
315 .layer(headers::ServedFileHeaders::new(&path))
316 .serve_dir(route_path(&path), dir);
317 }
318 for page in pages {
319 builder = builder.page(page);
320 }
321 for route in routes {
322 builder = builder.route(route);
323 }
324 if root_redirect.is_some() {
325 builder = builder.route(RouteFn::new(
326 http::Method::GET,
327 prefix_path,
328 panel_root_redirect,
329 ));
330 }
331 builder
332 .get_app_context_mut::<Panels>()
333 .expect("the shared panel state is installed above")
334 .0
335 .push(state);
336 Ok(builder)
337 }
338}
339
340impl Registry {
341 fn register_all(
344 &mut self,
345 registrations: Vec<Box<dyn super::register::Registration>>,
346 configuration_errors: Vec<DeclarationError>,
347 ) -> (Arc<Mounts>, Vec<DeclarationError>) {
348 for registration in registrations {
349 registration.register(self);
350 }
351 self.link_relations();
352 let mut errors = configuration_errors;
353 errors.append(&mut self.errors);
354 (Arc::new(std::mem::take(&mut self.mounts)), errors)
355 }
356
357 fn check_all(
360 &self,
361 db: &Db,
362 mounts: &Arc<Mounts>,
363 has_uploader: bool,
364 errors: &mut Vec<DeclarationError>,
365 ) -> Cx {
366 let cx = validation_cx(db, mounts);
367 for check in &self.page_checks {
368 check(&cx, errors);
369 }
370 for registered in &self.resources {
371 (registered.check)(&cx, errors);
372 if !has_uploader {
374 errors.extend(registered.file_fields.iter().map(|field| DeclarationError {
375 resource: Some(registered.name),
376 site: Site::Form,
377 kind: DeclarationErrorKind::FileFieldWithoutUploader {
378 field: field.clone(),
379 },
380 }));
381 }
382 }
383 cx
384 }
385}
386
387fn mount_errors(
389 builder: &RouterBuilder,
390 prefix: &str,
391 shell_assets: bool,
392 served_dirs: &[(String, std::path::PathBuf)],
393) -> Vec<DeclarationError> {
394 let mut errors = Vec::new();
395 let mut refuse = |kind| errors.push(DeclarationError::panel(kind));
396 if shell_assets && builder.get_app_context::<AssetConfig>().is_none() {
397 refuse(DeclarationErrorKind::ShellAssetsWithoutBundle);
398 }
399 if under_prefix(RUNTIME_PREFIX, prefix) || under_prefix(prefix, RUNTIME_PREFIX) {
400 refuse(DeclarationErrorKind::PrefixOverlapsRuntime {
401 prefix: prefix.to_string(),
402 });
403 }
404 for (index, (path, _)) in served_dirs.iter().enumerate() {
405 let root = served_root(path);
406 if served_dirs[..index]
407 .iter()
408 .any(|(seen, _)| served_root(seen) == root)
409 {
410 refuse(DeclarationErrorKind::ServeDirTwice { path: path.clone() });
411 }
412 }
413 if let Some(panels) = builder.get_app_context::<Panels>() {
414 for other in &panels.0 {
415 for (path, _) in served_dirs {
416 if other
417 .served_paths
418 .iter()
419 .any(|served| served_root(served) == served_root(path))
420 {
421 refuse(DeclarationErrorKind::ServeDirTaken {
422 path: path.clone(),
423 panel: other.prefix.clone(),
424 });
425 }
426 }
427 if under_prefix(&other.prefix, prefix) || under_prefix(prefix, &other.prefix) {
428 refuse(DeclarationErrorKind::PrefixOverlapsPanel {
429 other: other.prefix.clone(),
430 });
431 }
432 }
433 }
434 errors
435}
436
437fn install_shared(mut builder: RouterBuilder) -> RouterBuilder {
439 builder = builder.cookies();
440 if builder.get_app_context::<SessionConfig>().is_none() {
441 builder = builder.sessions(SessionConfig::builder().lifetime(SESSION_LIFETIME).build());
442 }
443 builder = builder
444 .layer(RuntimeGate::new())
445 .app_context(Panels::default())
446 .app_context(MountScope(|cx| current(cx).map(|panel| &*panel.mounts)))
447 .app_context(TenantSource(crate::auth::session_tenant));
448 if builder.get_app_context::<RuntimeSetup>().is_none() {
451 builder = builder.runtime();
452 }
453 if builder.get_app_context::<PrefetchMode>().is_none() {
454 builder = builder.prefetch(PrefetchMode::Never);
455 }
456 builder
457}
458
459pub(crate) fn panel_root_redirect(cx: &Cx, _body: Body) -> RouteFuture<'_> {
462 Box::pin(async move {
463 crate::auth::guard(cx)?;
465 let target = current(cx)
466 .and_then(|panel| panel.root_redirect.clone())
467 .ok_or_else(topcoat::router::error::not_found)?;
468 Err(redirect(target).into())
469 })
470}
471
472pub(super) fn is_directory_pattern(path: &str) -> bool {
474 Path::from_str(path)
475 .ok()
476 .and_then(|parsed| parsed.segments().next_back())
477 .is_some_and(|segment| segment.as_catch_all().is_some())
478}
479
480pub(super) fn validate_route_segment(
483 item: &'static str,
484 segment: &str,
485) -> Result<(), DeclarationErrorKind> {
486 match segment_fault(segment) {
487 Some(fault) => Err(DeclarationErrorKind::InvalidSegment {
488 item,
489 segment: segment.to_string(),
490 fault,
491 }),
492 None => Ok(()),
493 }
494}
495
496pub(super) type ResourceCheck = fn(&Cx, &mut Vec<DeclarationError>);
499
500pub(super) fn check_resource<R: Resource>(cx: &Cx, errors: &mut Vec<DeclarationError>) {
502 let Ok(declared) = require_mounted::<R>(cx) else {
503 return;
504 };
505 let tenancy = &declared.tenancy;
506 if let Some(Err(kind)) = tenancy.column_field() {
507 errors.push(DeclarationError::of::<R>(Site::Tenancy, kind));
508 }
509 if tenancy.via_is_single() == Some(true) {
510 errors.push(DeclarationError::of::<R>(
511 Site::Tenancy,
512 DeclarationErrorKind::TenancyViaOwnColumn,
513 ));
514 }
515 let mut form_errors = declared.form.declaration_errors();
516 form_errors.extend(declared.form.empty_choices());
517 let form_is_sound = form_errors.is_empty();
518 let view_errors = if declared.declares_view {
520 declared.view.declaration_errors()
521 } else {
522 Vec::new()
523 };
524 for (site, kinds) in [
525 (Site::Table, declared.table.declaration_errors()),
526 (Site::Form, form_errors),
527 (Site::View, view_errors),
528 ] {
529 errors.extend(
530 kinds
531 .into_iter()
532 .map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
533 );
534 }
535 for (site, kinds) in [
536 (Site::Table, declared.table.unavailable_sources(cx)),
537 (Site::View, declared.view.unavailable_sources(cx)),
538 ] {
539 errors.extend(
540 kinds
541 .into_iter()
542 .map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
543 );
544 }
545 check_actions(cx, &declared, errors);
546 check_form_declaration(cx, &declared, form_is_sound, errors);
547}
548
549fn check_actions<R: Resource>(cx: &Cx, declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
554 let actions = declared
555 .actions
556 .entries()
557 .iter()
558 .map(|action| (action.name, action.input))
559 .chain(
560 declared
561 .header_actions
562 .entries()
563 .iter()
564 .map(|action| (action.name, action.input)),
565 );
566 check_inputs::<R>(cx, actions, errors);
567}
568
569pub(super) type PageCheck = fn(&Cx, &mut Vec<DeclarationError>);
571
572pub(super) fn check_page<P: Page>(cx: &Cx, errors: &mut Vec<DeclarationError>) {
574 let actions = P::header_actions();
575 check_inputs::<P>(
576 cx,
577 actions
578 .entries()
579 .iter()
580 .map(|action| (action.name, action.input)),
581 errors,
582 );
583}
584
585fn check_inputs<T: 'static>(
589 cx: &Cx,
590 actions: impl Iterator<Item = (&'static str, InputSpec)>,
591 errors: &mut Vec<DeclarationError>,
592) {
593 let mut seen = std::collections::HashSet::new();
594 for (name, spec) in actions {
595 if !seen.insert(name) {
596 errors.push(DeclarationError::of::<T>(
597 Site::Registration,
598 DeclarationErrorKind::DuplicateAction { name },
599 ));
600 }
601 let input = (spec.schema)();
602 let mut kinds = input.declaration_errors();
603 kinds.extend(input.empty_choices());
604 let mut faults: Vec<ActionInputFault> = input
605 .fields()
606 .filter_map(|field| {
607 let name = field.name().to_string();
608 if crate::resource::RESERVED_KEYS.contains(&name.as_str()) {
609 Some(ActionInputFault::ReservedField(name))
610 } else if field.is_file() {
611 Some(ActionInputFault::FileField(name))
612 } else if field.parent_key().is_some() {
613 Some(ActionInputFault::DependentChoice(name))
614 } else {
615 None
616 }
617 })
618 .collect();
619 if !spec.takes_input && (spec.parse)(cx, &HashMap::new()).is_err() {
620 faults.push(ActionInputFault::RefusesEmpty);
621 }
622 kinds.extend(
623 faults
624 .into_iter()
625 .map(|fault| DeclarationErrorKind::ActionInput {
626 action: name,
627 fault,
628 }),
629 );
630 errors.extend(
631 kinds
632 .into_iter()
633 .map(|kind| DeclarationError::of::<T>(Site::Registration, kind)),
634 );
635 }
636}
637
638fn form_error<R: Resource>(kind: DeclarationErrorKind) -> DeclarationError {
640 DeclarationError::of::<R>(Site::Form, kind)
641}
642
643fn check_form_declaration<R: Resource>(
645 cx: &Cx,
646 declared: &Mounted<R>,
647 form_is_sound: bool,
648 errors: &mut Vec<DeclarationError>,
649) {
650 if !form_is_sound {
652 return;
653 }
654 if <R::Form as RecordForm>::HAS_FORM {
655 check_form_inner(cx, declared, errors);
656 } else if declared.can(cx, Ability::Create) {
657 errors.push(form_error::<R>(DeclarationErrorKind::CreateWithoutForm));
658 }
659}
660
661fn check_form_inner<R: Resource>(
662 cx: &Cx,
663 declared: &Mounted<R>,
664 errors: &mut Vec<DeclarationError>,
665) {
666 let (fields, form) = (declared.fields.as_slice(), &*declared.form);
667 for field in fields {
669 for key in &field.keys {
670 if !form.fields().any(|control| control.name() == key) {
671 errors.push(form_error::<R>(DeclarationErrorKind::MissingControl {
672 field: field.name.to_string(),
673 key: key.clone(),
674 }));
675 }
676 }
677 }
678 if let Some(column) = tenant_column(declared)
680 && let Some(field) = fields.iter().find(|field| field.keys.contains(&column))
681 {
682 errors.push(form_error::<R>(
683 DeclarationErrorKind::FormClaimsTenantColumn {
684 field: field.name.to_string(),
685 column,
686 },
687 ));
688 }
689 for field in form.fields().filter(|field| {
690 field
691 .as_choice()
692 .is_some_and(|choice| choice.has_composite_source())
693 }) {
694 errors.push(form_error::<R>(DeclarationErrorKind::CompositeKeyChoice {
695 field: field.name().to_string(),
696 }));
697 }
698 for field in form.fields() {
699 if let Some(source) = field
700 .as_choice()
701 .and_then(|choice| choice.unavailable_source(cx))
702 {
703 errors.push(form_error::<R>(
704 DeclarationErrorKind::UnregisteredOptionSource {
705 field: field.name().to_string(),
706 source,
707 },
708 ));
709 }
710 }
711 if declared.tenancy.via_is_single() == Some(false) {
712 check_via_foreign_keys(cx, declared, errors);
713 }
714 check_many_to_many(cx, declared, errors);
715 if declared.can(cx, Ability::Create) {
716 check_create_columns(declared, errors);
717 }
718 let columns = model::fields::<R::Model>();
719 for field in form.fields().filter(|field| field.is_unique()) {
720 let name = field.name();
721 let backed = columns
722 .iter()
723 .any(|column| column.name == name && column.unique);
724 if !backed {
725 errors.push(form_error::<R>(DeclarationErrorKind::UniqueWithoutIndex {
726 field: name.to_string(),
727 }));
728 }
729 if !field.is_required() && !field.is_nullable() {
730 errors.push(form_error::<R>(DeclarationErrorKind::OptionalUnique {
731 field: name.to_string(),
732 }));
733 }
734 }
735}
736
737fn check_many_to_many<R: Resource>(
741 cx: &Cx,
742 declared: &Mounted<R>,
743 errors: &mut Vec<DeclarationError>,
744) {
745 let Some(schema) = AppSchema::of(cx) else {
746 return;
747 };
748 for field in declared.form.fields() {
749 let name = field.name();
750 let choice = field
751 .as_choice()
752 .filter(|choice| choice.is_multiple() && choice.is_relationship());
753 let fault = if model::is_via::<R::Model>(name) {
754 match (choice, JoinTable::of::<R::Model>(&schema, name)) {
755 (_, Err(fault)) => Some(fault),
756 (None, Ok(_)) => Some(ManyToManyFault::NotMultipleChoice),
757 (Some(choice), Ok(join)) => (choice.source_model() != Some(join.target_model()))
758 .then_some(ManyToManyFault::OtherSource),
759 }
760 } else {
761 field
762 .as_choice()
763 .is_some_and(|choice| choice.is_multiple())
764 .then_some(ManyToManyFault::NoJoin)
765 };
766 if let Some(fault) = fault {
767 errors.push(form_error::<R>(DeclarationErrorKind::ManyToMany {
768 field: name.to_string(),
769 fault,
770 }));
771 }
772 }
773}
774
775fn tenant_column<R: Resource>(declared: &Mounted<R>) -> Option<String> {
777 declared
778 .tenancy
779 .column_field()
780 .and_then(Result::ok)
781 .map(|field| field.name.clone())
782}
783
784fn check_create_columns<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
786 let fields = &declared.fields;
787 let mut create_columns = Vec::new();
788 for column in &declared.create_columns {
789 match column {
790 Ok(name) => create_columns.push(name.as_str()),
791 Err(kind) => errors.push(DeclarationError::of::<R>(Site::Registration, kind.clone())),
792 }
793 }
794 let prefilled = model::prefilled_fields::<R::Model>();
795 let tenant = tenant_column(declared);
796 if let Some(column) = &tenant
797 && create_columns.contains(&column.as_str())
798 {
799 errors.push(DeclarationError::of::<R>(
800 Site::Registration,
801 DeclarationErrorKind::CreateColumnsNameTenant {
802 column: column.clone(),
803 },
804 ));
805 }
806 for field in &model::fields::<R::Model>() {
807 let name = field.name.as_str();
808 let filled = field.nullable
809 || field.relation
810 || prefilled.get(field.index).copied().unwrap_or(false)
811 || tenant.as_deref() == Some(name)
812 || fields.iter().any(|claim| claim.name == name)
813 || create_columns.contains(&name);
814 if !filled {
815 errors.push(form_error::<R>(DeclarationErrorKind::UnwrittenColumn {
816 column: name.to_string(),
817 }));
818 }
819 }
820}
821
822fn validation_cx(db: &Db, mounts: &Arc<Mounts>) -> Cx {
825 let mut app_context = topcoat::context::AppContext::new();
826 app_context.insert(db.clone());
827 app_context.insert(Arc::clone(mounts));
828 app_context.insert(MountScope(|cx| {
829 topcoat::context::try_app_context::<Arc<Mounts>>(cx).map(|mounts| &**mounts)
830 }));
831 Cx::new(Arc::new(app_context))
832}
833
834fn check_via_foreign_keys<R: Resource>(
838 cx: &Cx,
839 declared: &Mounted<R>,
840 errors: &mut Vec<DeclarationError>,
841) {
842 let form = &declared.form;
843 let Some(model::BelongsTo {
845 name: relation,
846 target: parent,
847 foreign_keys: keys,
848 }) = declared
849 .tenancy
850 .via_hop()
851 .and_then(model::belongs_to::<R::Model>)
852 else {
853 errors.push(DeclarationError::of::<R>(
854 Site::Tenancy,
855 DeclarationErrorKind::TenancyViaWithoutBelongsTo,
856 ));
857 return;
858 };
859 let unguarded: Vec<String> = keys
860 .into_iter()
861 .filter(|key| {
862 !form.fields().any(|field| {
863 field.name() == key
864 && field
865 .as_choice()
866 .and_then(|choice| choice.tenant_scoped_model(cx))
867 == Some(parent)
868 })
869 })
870 .collect();
871 if !unguarded.is_empty() {
872 errors.push(form_error::<R>(DeclarationErrorKind::UnguardedForeignKey {
873 relation,
874 keys: unguarded,
875 }));
876 }
877}
878
879fn served_root(path: &str) -> &str {
882 path.rsplit_once("{*").map_or(path, |(root, _)| root)
883}
884
885pub(crate) fn route_path(path: &str) -> topcoat::router::PathBuf {
887 Path::from_str(path)
888 .expect("panel route paths are well-formed")
889 .to_owned()
890}
891
892#[cfg(test)]
893mod tests;