Skip to main content

tablo_core/panel/
build.rs

1//! Mounting a panel: [`RouterBuilderPanelExt::panel`], the declaration
2//! checks it runs, and the route-path helpers.
3
4use 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    state::{PanelState, Panels, current, under_prefix},
26};
27use crate::{
28    DeclarationError, DeclarationErrorKind, MountError, Site,
29    auth::{PanelGate, RuntimeGate, SESSION_LIFETIME},
30    declaration::segment_fault,
31    form::RecordForm,
32    policy::Ability,
33    resource::{MountScope, Mounted, Mounts, Resource, require_mounted},
34    tenancy::TenantSource,
35    topcoat_compat::RUNTIME_PREFIX,
36};
37
38/// Mounts a [`Panel`] on a router the app owns.
39///
40/// ```text
41/// use tablo::prelude::*;
42///
43/// let router = Router::builder()
44///     .discover()
45///     .app_context(db)
46///     .assets(bundle)
47///     .panel(Panel::new("admin").resource::<UserResource>())?
48///     .panel(Panel::new("portal").auth(Auth::custom(Members)).resource::<OrderResource>())?
49///     .build();
50/// ```
51pub trait RouterBuilderPanelExt: Sized {
52    /// Mounts `panel` at its prefix with its routes, shell layout, and gating layers.
53    fn panel(self, panel: Panel) -> Result<Self>;
54}
55
56impl RouterBuilderPanelExt for RouterBuilder {
57    fn panel(self, panel: Panel) -> Result<Self> {
58        panel.mount(self)
59    }
60}
61
62impl Panel {
63    /// A context outside any request in which the panel's resources answer as it mounts them:
64    /// what a background job or a test passes to [`scoped_query`](crate::scoped_query),
65    /// [`can`](crate::can), [`write_create`](crate::write_create) and the other entry points that
66    /// answer from a mounted def. It holds `db` and the panel's resources, and no request,
67    /// session or tenant; add a tenant with `cx.with(Tenant(id))`.
68    ///
69    /// Like a request's, the context is one unit of work: loads it memoizes stay cached for its
70    /// lifetime, so a job builds one per run.
71    ///
72    /// # Errors
73    ///
74    /// The declaration errors [`panel`](RouterBuilderPanelExt::panel) refuses the resources with.
75    pub fn context(self, db: &Db) -> Result<Cx> {
76        let Panel {
77            prefix,
78            registrations,
79            configuration_errors,
80            ..
81        } = self;
82        let mut registry = Registry::new(prefix.clone(), Some(db.schema().clone()));
83        let (mounts, mut errors) = registry.register_all(registrations, configuration_errors);
84        let cx = registry.check_all(db, &mounts, &mut errors);
85        if !errors.is_empty() {
86            return Err(MountError::new(&prefix, errors).into());
87        }
88        Ok(cx)
89    }
90
91    fn mount(self, mut builder: RouterBuilder) -> Result<RouterBuilder> {
92        let db = builder.get_app_context::<Db>().cloned();
93        let mut registry = Registry::new(
94            self.prefix.clone(),
95            db.as_ref().map(|db| db.schema().clone()),
96        );
97        let Panel {
98            prefix,
99            shell_assets,
100            brand,
101            dark_mode,
102            layout,
103            registrations,
104            frame_ancestors,
105            configuration_errors,
106            uploads,
107            served_dirs,
108            login_hint,
109            auth,
110        } = self;
111        let (mounts, mut errors) = registry.register_all(registrations, configuration_errors);
112        errors.extend(mount_errors(
113            &builder,
114            &prefix,
115            shell_assets.is_some(),
116            &served_dirs,
117        ));
118        match &db {
119            Some(db) => {
120                registry.check_all(db, &mounts, &mut errors);
121                if let Err(kind) = crate::auth::check_models_registered(db, &auth) {
122                    errors.push(DeclarationError::panel(kind));
123                }
124            }
125            None => errors.push(DeclarationError::panel(DeclarationErrorKind::MissingDb)),
126        }
127        if !errors.is_empty() {
128            return Err(MountError::new(&prefix, errors).into());
129        }
130        if builder.get_app_context::<Panels>().is_none() {
131            builder = install_shared(builder);
132        }
133        let Registry {
134            urls,
135            nav_items,
136            pages,
137            routes,
138            root,
139            children,
140            ..
141        } = registry;
142        let root_redirect = match root {
143            Some(Root::Redirect(target)) => Some(target),
144            Some(Root::Home) | None => None,
145        };
146        let served_paths = served_dirs.iter().map(|(path, _)| path.clone()).collect();
147        let state = Arc::new(PanelState {
148            prefix: prefix.clone(),
149            nav_items,
150            brand,
151            dark_mode: dark_mode.unwrap_or(false),
152            shell_assets,
153            children,
154            mounts,
155            root_redirect: root_redirect.clone(),
156            auth,
157            login_hint,
158            uploads,
159            served_paths,
160            urls,
161        });
162        let prefix_path = route_path(&prefix);
163        builder =
164            builder.layer(topcoat::router::BodyLimit::max(MAX_FORM_BYTES).at(prefix_path.clone()));
165        if let Some(directive) = frame_ancestors {
166            builder = builder.layer(headers::FrameAncestors::new(directive, prefix.clone()));
167        }
168        // Registered last of the prefix's layers, so it runs first.
169        builder = builder.layer(PanelGate::new(Arc::clone(&state)));
170        // The login route carries its own cap scoped by path.
171        if state.gates() {
172            let login_path = route_path(&format!("{prefix}/login"));
173            let logout_path = route_path(&format!("{prefix}/logout"));
174            let tenant_path = route_path(&format!("{prefix}/tenant"));
175            builder = builder
176                .layer(
177                    topcoat::router::BodyLimit::max(crate::auth::MAX_LOGIN_BYTES)
178                        .at(login_path.clone()),
179                )
180                .route(RouteFn::new(
181                    http::Method::GET,
182                    login_path.clone(),
183                    crate::auth::login_page,
184                ))
185                .route(RouteFn::new(
186                    http::Method::POST,
187                    login_path,
188                    crate::auth::login_post,
189                ))
190                .route(RouteFn::new(
191                    http::Method::POST,
192                    logout_path,
193                    crate::auth::logout_post,
194                ))
195                .route(RouteFn::new(
196                    http::Method::POST,
197                    tenant_path,
198                    crate::auth::tenant_post,
199                ));
200        }
201        builder = builder.layout(LayoutFn::new(
202            prefix_path.clone(),
203            layout.unwrap_or(Panel::layout_shell),
204        ));
205        for (path, dir) in served_dirs {
206            builder = builder
207                .layer(headers::ServedFileHeaders::new(&path))
208                .serve_dir(route_path(&path), dir);
209        }
210        for page in pages {
211            builder = builder.page(page);
212        }
213        for route in routes {
214            builder = builder.route(route);
215        }
216        if root_redirect.is_some() {
217            builder = builder.route(RouteFn::new(
218                http::Method::GET,
219                prefix_path,
220                panel_root_redirect,
221            ));
222        }
223        builder
224            .get_app_context_mut::<Panels>()
225            .expect("the shared panel state is installed above")
226            .0
227            .push(state);
228        Ok(builder)
229    }
230}
231
232impl Registry {
233    /// Registers `registrations` and links their relations, returning the mounts and every error,
234    /// `configuration_errors` first.
235    fn register_all(
236        &mut self,
237        registrations: Vec<Box<dyn super::register::Registration>>,
238        configuration_errors: Vec<DeclarationError>,
239    ) -> (Arc<Mounts>, Vec<DeclarationError>) {
240        for registration in registrations {
241            registration.register(self);
242        }
243        self.link_relations();
244        let mut errors = configuration_errors;
245        errors.append(&mut self.errors);
246        (Arc::new(std::mem::take(&mut self.mounts)), errors)
247    }
248
249    /// Checks every registered resource against `db`, returning the context the checks ran in.
250    fn check_all(&self, db: &Db, mounts: &Arc<Mounts>, errors: &mut Vec<DeclarationError>) -> Cx {
251        let cx = validation_cx(db, mounts);
252        for registered in &self.resources {
253            (registered.check)(&cx, errors);
254        }
255        cx
256    }
257}
258
259/// What refuses a panel at `prefix` before its resources are checked.
260fn mount_errors(
261    builder: &RouterBuilder,
262    prefix: &str,
263    shell_assets: bool,
264    served_dirs: &[(String, std::path::PathBuf)],
265) -> Vec<DeclarationError> {
266    let mut errors = Vec::new();
267    let mut refuse = |kind| errors.push(DeclarationError::panel(kind));
268    if shell_assets && builder.get_app_context::<AssetConfig>().is_none() {
269        refuse(DeclarationErrorKind::ShellAssetsWithoutBundle);
270    }
271    if under_prefix(RUNTIME_PREFIX, prefix) || under_prefix(prefix, RUNTIME_PREFIX) {
272        refuse(DeclarationErrorKind::PrefixOverlapsRuntime {
273            prefix: prefix.to_string(),
274        });
275    }
276    for (index, (path, _)) in served_dirs.iter().enumerate() {
277        let root = served_root(path);
278        if served_dirs[..index]
279            .iter()
280            .any(|(seen, _)| served_root(seen) == root)
281        {
282            refuse(DeclarationErrorKind::ServeDirTwice { path: path.clone() });
283        }
284    }
285    if let Some(panels) = builder.get_app_context::<Panels>() {
286        for other in &panels.0 {
287            for (path, _) in served_dirs {
288                if other
289                    .served_paths
290                    .iter()
291                    .any(|served| served_root(served) == served_root(path))
292                {
293                    refuse(DeclarationErrorKind::ServeDirTaken {
294                        path: path.clone(),
295                        panel: other.prefix.clone(),
296                    });
297                }
298            }
299            if under_prefix(&other.prefix, prefix) || under_prefix(prefix, &other.prefix) {
300                refuse(DeclarationErrorKind::PrefixOverlapsPanel {
301                    other: other.prefix.clone(),
302                });
303            }
304        }
305    }
306    errors
307}
308
309/// Installs what every panel on a router shares.
310fn install_shared(mut builder: RouterBuilder) -> RouterBuilder {
311    builder = builder.cookies();
312    if builder.get_app_context::<SessionConfig>().is_none() {
313        builder = builder.sessions(SessionConfig::builder().lifetime(SESSION_LIFETIME).build());
314    }
315    builder = builder
316        .layer(RuntimeGate::new())
317        .app_context(Panels::default())
318        .app_context(MountScope(|cx| current(cx).map(|panel| &*panel.mounts)))
319        .app_context(TenantSource(crate::auth::session_tenant));
320    // The runtime layer has no path, so a page re-run reaches the panel's layers already rewritten
321    // to a `GET`.
322    if builder.get_app_context::<RuntimeSetup>().is_none() {
323        builder = builder.runtime();
324    }
325    if builder.get_app_context::<PrefetchMode>().is_none() {
326        builder = builder.prefetch(PrefetchMode::Never);
327    }
328    builder
329}
330
331/// Redirects the panel root of a panel with no [`home`](Panel::home) page to the first declared
332/// resource's list.
333pub(crate) fn panel_root_redirect(cx: &Cx, _body: Body) -> RouteFuture<'_> {
334    Box::pin(async move {
335        // Re-checks the resolved user so a mis-mounted gate cannot leak the slug.
336        crate::auth::guard(cx)?;
337        let target = current(cx)
338            .and_then(|panel| panel.root_redirect.clone())
339            .ok_or_else(topcoat::router::error::not_found)?;
340        Err(redirect(target).into())
341    })
342}
343
344/// Reports whether `path` is a route pattern ending in a catch-all.
345pub(super) fn is_directory_pattern(path: &str) -> bool {
346    Path::from_str(path)
347        .ok()
348        .and_then(|parsed| parsed.segments().next_back())
349        .is_some_and(|segment| segment.as_catch_all().is_some())
350}
351
352/// Validates one path segment a panel derives routes from, refusing anything that cannot serve as a
353/// literal URL segment.
354pub(super) fn validate_route_segment(
355    item: &'static str,
356    segment: &str,
357) -> Result<(), DeclarationErrorKind> {
358    match segment_fault(segment) {
359        Some(fault) => Err(DeclarationErrorKind::InvalidSegment {
360            item,
361            segment: segment.to_string(),
362            fault,
363        }),
364        None => Ok(()),
365    }
366}
367
368/// A resource's declaration check: monomorphized once per registered resource, run by
369/// [`RouterBuilderPanelExt::panel`] with the app's values, the panel's mounts and no request.
370pub(super) type ResourceCheck = fn(&Cx, &mut Vec<DeclarationError>);
371
372/// Checks what a mounted resource promises before the panel serves it.
373pub(super) fn check_resource<R: Resource>(cx: &Cx, errors: &mut Vec<DeclarationError>) {
374    let Ok(declared) = require_mounted::<R>(cx) else {
375        return;
376    };
377    let tenancy = &declared.tenancy;
378    if let Some(Err(kind)) = tenancy.column_field() {
379        errors.push(DeclarationError::of::<R>(Site::Tenancy, kind));
380    }
381    if tenancy.via_is_single() == Some(true) {
382        errors.push(DeclarationError::of::<R>(
383            Site::Tenancy,
384            DeclarationErrorKind::TenancyViaOwnColumn,
385        ));
386    }
387    let form_errors = declared.form.declaration_errors();
388    let form_is_sound = form_errors.is_empty();
389    let view_errors = if declared.has_own_view() {
390        declared.view().declaration_errors()
391    } else {
392        Vec::new()
393    };
394    for (site, kinds) in [
395        (Site::Table, declared.table.declaration_errors()),
396        (Site::Form, form_errors),
397        (Site::View, view_errors),
398    ] {
399        errors.extend(
400            kinds
401                .into_iter()
402                .map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
403        );
404    }
405    check_actions(&declared, errors);
406    check_form_declaration(cx, &declared, form_is_sound, errors);
407}
408
409/// Every custom action's name is distinct among the resource's actions: the routes dispatch by
410/// it. [`ResourceDef::action`](crate::ResourceDef::action) checks each name is a route segment as
411/// it compiles.
412fn check_actions<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
413    let mut seen = std::collections::HashSet::new();
414    for action in declared.actions.entries() {
415        if !seen.insert(action.name) {
416            errors.push(DeclarationError::of::<R>(
417                Site::Registration,
418                DeclarationErrorKind::DuplicateAction { name: action.name },
419            ));
420        }
421    }
422}
423
424/// A mistake in `R`'s form.
425fn form_error<R: Resource>(kind: DeclarationErrorKind) -> DeclarationError {
426    DeclarationError::of::<R>(Site::Form, kind)
427}
428
429/// Checks a resource's form declaration against its record form.
430fn check_form_declaration<R: Resource>(
431    cx: &Cx,
432    declared: &Mounted<R>,
433    form_is_sound: bool,
434    errors: &mut Vec<DeclarationError>,
435) {
436    // A misdeclared field's placeholder name would only echo as an unbound control.
437    if !form_is_sound {
438        return;
439    }
440    check_layout(declared, errors);
441    if <R::Form as RecordForm>::HAS_FORM {
442        check_form_inner(cx, declared, errors);
443    } else if declared.can(cx, Ability::Create) {
444        errors.push(form_error::<R>(DeclarationErrorKind::CreateWithoutForm));
445    }
446}
447
448/// Every control is one of the record form's, and every record-form key has its control.
449fn check_layout<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
450    let (fields, form) = (declared.fields.as_slice(), &*declared.form);
451    // The schema refuses two controls sharing a key, so each key binds one control.
452    for control in form.fields() {
453        if !fields
454            .iter()
455            .any(|field| field.keys.iter().any(|key| key == control.name()))
456        {
457            errors.push(form_error::<R>(DeclarationErrorKind::UnboundControl {
458                control: control.name().to_string(),
459            }));
460        }
461    }
462    for field in fields {
463        for key in &field.keys {
464            if !form.fields().any(|control| control.name() == key) {
465                errors.push(form_error::<R>(DeclarationErrorKind::MissingControl {
466                    field: field.name.to_string(),
467                    key: key.clone(),
468                }));
469            }
470        }
471    }
472}
473
474fn check_form_inner<R: Resource>(
475    cx: &Cx,
476    declared: &Mounted<R>,
477    errors: &mut Vec<DeclarationError>,
478) {
479    let (fields, form) = (declared.fields.as_slice(), &*declared.form);
480    // The framework stamps the tenant column on create.
481    if let Some(column) = tenant_column(declared)
482        && let Some(field) = fields.iter().find(|field| field.keys.contains(&column))
483    {
484        errors.push(form_error::<R>(
485            DeclarationErrorKind::FormClaimsTenantColumn {
486                field: field.name.to_string(),
487                column,
488            },
489        ));
490    }
491    for field in form.fields().filter(|field| {
492        field
493            .as_choice()
494            .is_some_and(|choice| choice.has_composite_source())
495    }) {
496        errors.push(form_error::<R>(DeclarationErrorKind::CompositeKeyChoice {
497            field: field.name().to_string(),
498        }));
499    }
500    for field in form.fields() {
501        if let Some(source) = field
502            .as_choice()
503            .and_then(|choice| choice.unavailable_source(cx))
504        {
505            errors.push(form_error::<R>(
506                DeclarationErrorKind::UnregisteredOptionSource {
507                    field: field.name().to_string(),
508                    source,
509                },
510            ));
511        }
512    }
513    if declared.tenancy.via_is_single() == Some(false) {
514        check_via_foreign_keys(cx, declared, errors);
515    }
516    if declared.can(cx, Ability::Create) {
517        check_create_columns(declared, errors);
518    }
519    let model = R::Model::schema();
520    let root = model.as_root_unwrap();
521    for field in form.fields().filter(|field| field.is_unique()) {
522        let name = field.name();
523        let backed = root
524            .fields
525            .iter()
526            .filter(|field| field.name.app_unwrap() == name)
527            .any(|field| crate::schema::lens_field_unique(field, root));
528        if !backed {
529            errors.push(form_error::<R>(DeclarationErrorKind::UniqueWithoutIndex {
530                field: name.to_string(),
531            }));
532        }
533        if !field.is_required() && !field.is_nullable() {
534            errors.push(form_error::<R>(DeclarationErrorKind::OptionalUnique {
535                field: name.to_string(),
536            }));
537        }
538    }
539}
540
541/// Names `R`'s own tenant column.
542fn tenant_column<R: Resource>(declared: &Mounted<R>) -> Option<String> {
543    declared
544        .tenancy
545        .column_field()
546        .and_then(Result::ok)
547        .map(|field| field.name.clone())
548}
549
550/// Checks that every non-nullable column a create needs has a writer.
551fn check_create_columns<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
552    let (fields, create_columns) = (&declared.fields, &declared.create_columns);
553    let prefilled = crate::form::prefilled_fields::<R::Model>();
554    let tenant = tenant_column(declared);
555    if let Some(column) = &tenant
556        && create_columns.contains(&column.as_str())
557    {
558        errors.push(DeclarationError::of::<R>(
559            Site::Registration,
560            DeclarationErrorKind::CreateColumnsNameTenant {
561                column: column.clone(),
562            },
563        ));
564    }
565    let model = R::Model::schema();
566    let root = model.as_root_unwrap();
567    for &column in create_columns {
568        if !root
569            .fields
570            .iter()
571            .any(|field| field.name.app.as_deref() == Some(column))
572        {
573            errors.push(DeclarationError::of::<R>(
574                Site::Registration,
575                DeclarationErrorKind::UnknownCreateColumn { column },
576            ));
577        }
578    }
579    for (index, field) in root.fields.iter().enumerate() {
580        let Some(name) = field.name.app.as_deref() else {
581            continue;
582        };
583        let filled = field.nullable()
584            || field.is_relation()
585            || prefilled.get(index).copied().unwrap_or(false)
586            || tenant.as_deref() == Some(name)
587            || fields.iter().any(|claim| claim.name == name)
588            || create_columns.contains(&name);
589        if !filled {
590            errors.push(form_error::<R>(DeclarationErrorKind::UnwrittenColumn {
591                column: name.to_string(),
592            }));
593        }
594    }
595}
596
597/// Builds the context for the mount-time declaration checks and [`Panel::context`] from `db` and
598/// the panel's `mounts`, with no request.
599fn validation_cx(db: &Db, mounts: &Arc<Mounts>) -> Cx {
600    let mut app_context = topcoat::context::AppContext::new();
601    app_context.insert(db.clone());
602    app_context.insert(Arc::clone(mounts));
603    app_context.insert(MountScope(|cx| {
604        topcoat::context::try_app_context::<Arc<Mounts>>(cx).map(|mounts| &**mounts)
605    }));
606    Cx::new(Arc::new(app_context))
607}
608
609/// A `Tenancy::via` resource inherits its tenant from the parent its foreign key names, so the
610/// form must write that key through a relationship field over the parent's tenant-scoped
611/// resource: the write re-checks only such a field's key against the request's tenant.
612fn check_via_foreign_keys<R: Resource>(
613    cx: &Cx,
614    declared: &Mounted<R>,
615    errors: &mut Vec<DeclarationError>,
616) {
617    let form = &declared.form;
618    let Some((relation, parent, keys)) = via_relation(declared) else {
619        errors.push(DeclarationError::of::<R>(
620            Site::Tenancy,
621            DeclarationErrorKind::TenancyViaWithoutBelongsTo,
622        ));
623        return;
624    };
625    let unguarded: Vec<String> = keys
626        .into_iter()
627        .filter(|key| {
628            !form.fields().any(|field| {
629                field.name() == key
630                    && field
631                        .as_choice()
632                        .and_then(|choice| choice.tenant_scoped_model(cx))
633                        == Some(parent)
634            })
635        })
636        .collect();
637    if !unguarded.is_empty() {
638        errors.push(form_error::<R>(DeclarationErrorKind::UnguardedForeignKey {
639            relation,
640            keys: unguarded,
641        }));
642    }
643}
644
645/// The name, parent model and foreign-key columns of the `belongs_to` relation a `Tenancy::via`
646/// lens steps through first; `None` when the first step is no `belongs_to`.
647fn via_relation<R: Resource>(
648    declared: &Mounted<R>,
649) -> Option<(String, toasty::schema::app::ModelId, Vec<String>)> {
650    let hop = declared.tenancy.via_hop()?;
651    let model = R::Model::schema();
652    let root = model.as_root()?;
653    let field = root.fields.get(hop)?;
654    let toasty::schema::app::FieldTy::BelongsTo(relation) = &field.ty else {
655        return None;
656    };
657    let keys = relation
658        .foreign_key
659        .fields
660        .iter()
661        .filter_map(|key| root.fields.get(key.source.index))
662        .map(|field| field.name.app_unwrap().to_string())
663        .collect::<Vec<_>>();
664    (!keys.is_empty()).then(|| (field.name.app_unwrap().to_string(), relation.target, keys))
665}
666
667/// A served directory's pattern without its catch-all's name: the router treats
668/// `/uploads/{*file}` and `/uploads/{*path}` as one route.
669fn served_root(path: &str) -> &str {
670    path.rsplit_once("{*").map_or(path, |(root, _)| root)
671}
672
673/// Parses a panel route path, panicking on malformed input.
674pub(crate) fn route_path(path: &str) -> topcoat::router::PathBuf {
675    Path::from_str(path)
676        .expect("panel route paths are well-formed")
677        .to_owned()
678}
679
680#[cfg(test)]
681mod tests;