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