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    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
39/// Mounts a [`Panel`] on a router the app owns.
40///
41/// ```text
42/// use tablo::prelude::*;
43///
44/// let router = Router::builder()
45///     .discover()
46///     .app_context(db)
47///     .assets(bundle)
48///     .panel(Panel::new("admin").resource::<UserResource>())?
49///     .panel(Panel::new("portal").auth(Auth::custom(Members)).resource::<OrderResource>())?
50///     .build();
51/// ```
52pub trait RouterBuilderPanelExt: Sized {
53    /// Mounts `panel` at its prefix with its routes, shell layout, and gating layers.
54    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 &registry.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        // Registered last of the prefix's layers, so it runs first.
155        builder = builder.layer(PanelGate::new(Arc::clone(&state)));
156        // The login route carries its own cap scoped by path.
157        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
218/// What refuses a panel at `prefix` before its resources are checked.
219fn 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
268/// Installs what every panel on a router shares.
269fn 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    // The runtime layer has no path, so a page re-run reaches the panel's layers already rewritten
282    // to a `GET`.
283    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
292/// Redirects the panel root of a panel with no [`home`](Panel::home) page to the first declared
293/// resource's list.
294pub(crate) fn panel_root_redirect(cx: &Cx, _body: Body) -> RouteFuture<'_> {
295    Box::pin(async move {
296        // Re-checks the resolved user so a mis-mounted gate cannot leak the slug.
297        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
305/// Reports whether `path` is a route pattern ending in a catch-all.
306pub(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
313/// Validates one path segment a panel derives routes from, refusing anything that cannot serve as a
314/// literal URL segment.
315pub(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
329/// A resource's declaration check: monomorphized once per registered resource, run by
330/// [`RouterBuilderPanelExt::panel`] with the app's values, the panel's mounts and no request.
331pub(super) type ResourceCheck = fn(&Cx, &mut Vec<DeclarationError>);
332
333/// Checks what a mounted resource promises before the panel serves it.
334pub(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
370/// Every custom action's name is distinct among the resource's actions: the routes dispatch by
371/// it. [`ResourceDef::action`](crate::ResourceDef::action) checks each name is a route segment as
372/// it compiles.
373fn 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
385/// A mistake in `R`'s form.
386fn form_error<R: Resource>(kind: DeclarationErrorKind) -> DeclarationError {
387    DeclarationError::of::<R>(Site::Form, kind)
388}
389
390/// Checks a resource's form declaration against its record form.
391fn 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            // A misdeclared field's placeholder name would only echo as an unbound control.
402            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        // An empty submission resolves wherever the schema lets one through.
444        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    // The framework stamps the tenant column on create.
460    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
515/// Names `R`'s own tenant column.
516fn 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
524/// Checks that every non-nullable column a create needs has a writer.
525fn 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
571/// Builds the context for the mount-time declaration checks from the app's values and the panel's
572/// `mounts`, with no request.
573fn 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
583/// A `Tenancy::via` resource inherits its tenant from the parent its foreign key names, so the
584/// form must write that key through a relationship field over the parent's tenant-scoped
585/// resource: the write re-checks only such a field's key against the request's tenant.
586fn 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
619/// The name, parent model and foreign-key columns of the `belongs_to` relation a `Tenancy::via`
620/// lens steps through first; `None` when the first step is no `belongs_to`.
621fn 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
641/// A served directory's pattern without its catch-all's name: the router treats
642/// `/uploads/{*file}` and `/uploads/{*path}` as one route.
643fn served_root(path: &str) -> &str {
644    path.rsplit_once("{*").map_or(path, |(root, _)| root)
645}
646
647/// Parses a panel route path, panicking on malformed input.
648pub(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;