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::CookieLayer,
12    metadata::{Metadata, Robots},
13    router::{
14        Body, LayoutFn, Methods, PageFn, Path, RouteFn, RouteFuture, RouterBuilder,
15        RouterBuilderDirectoryExt, error::redirect,
16    },
17    runtime::{PrefetchMode, RuntimeLayer},
18};
19
20use super::{
21    Panel, Root, error_page,
22    files::served_root,
23    headers::{self, ServedDir},
24    host::PanelHost,
25    register::Registry,
26    shell::{LANGUAGE_SEGMENT, language_post},
27    state::{PanelState, Panels, current, under_prefix},
28};
29use crate::{
30    DeclarationError, DeclarationErrorKind, MountError, Site,
31    auth::{AuthHost, PagePanelStamp, PanelGate, Realm, RuntimeGate},
32    bus::NotificationBus,
33    declaration::segment_fault,
34    naming::route_path,
35    resource::{MountScope, Mounts},
36    tenancy::TenantSource,
37    toasty_compat::model::AppSchema,
38    topcoat_compat::RUNTIME_PREFIX,
39};
40
41mod handle;
42
43pub use self::handle::PanelHandle;
44use self::handle::request_mounts;
45
46/// Mounts a [`Panel`] on a router the app owns.
47///
48/// ```rust,no_run
49/// # use tablo_core::{Auth, Authenticator, NoForm, Panel, PanelUser, Resource, RouterBuilderPanelExt};
50/// # use topcoat::{context::Cx, router::{Router, RouterBuilderDiscoverExt}};
51/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
52/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
53/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
54/// # struct Order { #[key] #[auto] id: uuid::Uuid, total: i64 }
55/// # struct UserResource;
56/// # impl Resource for UserResource { type Model = User; type Form = NoForm<User>; }
57/// # struct OrderResource;
58/// # impl Resource for OrderResource { type Model = Order; type Form = NoForm<Order>; }
59/// # struct Member;
60/// # impl PanelUser for Member {
61/// #     fn user_id(&self) -> String { String::new() }
62/// #     fn display_name(&self) -> &str { "" }
63/// #     fn can_access_panel(&self, _panel: &str) -> bool { true }
64/// # }
65/// # struct Members;
66/// # impl Authenticator for Members {
67/// #     type User = Member;
68/// #     async fn verify(&self, _: &Cx, _: &str, _: &str) -> topcoat::Result<Option<Member>> { Ok(None) }
69/// #     async fn find_by_id(&self, _: &Cx, _: &str) -> topcoat::Result<Option<Member>> { Ok(None) }
70/// # }
71/// # fn main() -> topcoat::Result<()> {
72/// # let db: toasty::Db = todo!();
73/// let router = Router::builder()
74///     .discover()
75///     .with(db)
76///     .panel(Panel::new("admin").resource::<UserResource>())?
77///     .panel(Panel::new("portal").auth(Auth::custom(Members)).resource::<OrderResource>())?
78///     .build();
79/// # let _ = router;
80/// # Ok(())
81/// # }
82/// ```
83pub trait RouterBuilderPanelExt: Sized {
84    /// Mounts `panel` at its prefix with its routes, shell layout, and gating layers.
85    ///
86    /// The panel reads the router's `Db` and asset bundle, so `.with(db)` and
87    /// `.assets(..)` come first. The first panel mounted also installs what every panel shares:
88    /// cookies, the gate over Topcoat's runtime endpoints, and Topcoat's runtime layer with link
89    /// prefetching off unless the router already set those up. A panel keeps its sessions in its
90    /// own cookie, so the app's Topcoat sessions, if any, stay the app's. The runtime layer has no
91    /// path, so mount panels after the app's own pathless layers: a page re-run must reach
92    /// those layers already rewritten to a `GET`.
93    ///
94    /// # Errors
95    ///
96    /// A [`MountError`](crate::MountError) listing every declaration mistake, each a
97    /// [`DeclarationErrorKind`](crate::DeclarationErrorKind).
98    fn panel(self, panel: Panel) -> Result<Self>;
99
100    /// The panel mounted at `prefix`, for code that runs outside a request; `None` when no panel
101    /// is mounted there. `prefix` is spelled as for [`Panel::new`]: `"admin"` and `"/admin/"`
102    /// name the same panel.
103    fn panel_handle(&self, prefix: &str) -> Option<PanelHandle>;
104}
105
106impl RouterBuilderPanelExt for RouterBuilder {
107    fn panel(self, panel: Panel) -> Result<Self> {
108        panel.mount(self)
109    }
110
111    fn panel_handle(&self, prefix: &str) -> Option<PanelHandle> {
112        let panel = self
113            .try_context::<Panels>()?
114            .by_prefix(&crate::naming::normalize_prefix(prefix))?;
115        Some(PanelHandle {
116            db: self.try_context::<Db>()?.clone(),
117            mounts: Arc::clone(&panel.mounts),
118            catalog: Arc::clone(&panel.realm.catalog),
119            panel: Some(Arc::clone(panel)),
120            prefix: panel.realm.prefix.clone(),
121            bus: self
122                .try_context::<NotificationBus>()
123                .cloned()
124                .unwrap_or_else(NotificationBus::new),
125        })
126    }
127}
128
129impl Panel {
130    fn mount(self, mut builder: RouterBuilder) -> Result<RouterBuilder> {
131        let db = builder.try_context::<Db>().cloned();
132        let services = crate::auth::mount::Services::of(&builder);
133        let mut registry = Registry::new(self.prefix.clone(), db.as_ref().map(AppSchema::of_db));
134        let Panel {
135            prefix,
136            shell_assets,
137            brand,
138            dark_mode,
139            environment,
140            layout,
141            registrations,
142            content_security_policy,
143            content_security_policy_sources,
144            strict_transport_security,
145            configuration_errors,
146            uploads,
147            served_dirs,
148            login_hint,
149            user_menu,
150            auth,
151            mut endpoints,
152            notifications,
153            time_zone,
154            locales,
155            translations,
156            global_search,
157            limits,
158            commit_hooks,
159            audit_log,
160        } = self;
161        let (mounts, mut errors) =
162            registry.register_all(registrations, audit_log, configuration_errors);
163        let serves_tenants = serves_tenants(&auth, &mounts);
164        errors.extend(registry.unoffered_auth_pages(&auth));
165        errors.extend(mount_errors(
166            &builder,
167            &prefix,
168            shell_assets.is_some(),
169            &served_dirs,
170        ));
171        match &db {
172            Some(db) => {
173                registry.check_all(
174                    db,
175                    &mounts,
176                    serves_tenants,
177                    uploads.is_some(),
178                    &limits,
179                    &mut errors,
180                );
181                let toggles = registry.resources.iter().any(|resource| resource.toggles);
182                errors.extend(
183                    crate::auth::mount::declaration_errors(
184                        db,
185                        &auth,
186                        toggles,
187                        notifications,
188                        services,
189                    )
190                    .into_iter()
191                    .map(DeclarationError::panel),
192                );
193                errors.extend(super::audit::declaration_error(db, &registry.urls));
194            }
195            None => errors.push(DeclarationError::panel(DeclarationErrorKind::MissingDb)),
196        }
197        let catalog = build_catalog(&locales, &translations, &mut errors);
198        let panel_headers = match content_security_policy
199            .resolve(&content_security_policy_sources)
200            .and_then(|policy| {
201                headers::PanelHeaders::new(
202                    prefix.clone(),
203                    policy.as_deref(),
204                    strict_transport_security,
205                )
206            }) {
207            Ok(layer) => Some(layer),
208            Err(kind) => {
209                errors.push(DeclarationError::panel(kind));
210                None
211            }
212        };
213        if !errors.is_empty() {
214            return Err(MountError::new(&prefix, errors).into());
215        }
216        let catalog = Arc::new(catalog.expect("a catalog that failed to build fails the mount"));
217        if builder.try_context::<Panels>().is_none() {
218            builder = install_shared(builder);
219        }
220        let pages = super::area::settings_paths(&prefix, &registry.auth_pages);
221        let Registry {
222            urls,
223            mounted_pages,
224            nav_items,
225            areas,
226            searches,
227            pages: mut page_routes,
228            routes,
229            root,
230            children,
231            ..
232        } = registry;
233        let searches = if global_search {
234            super::search::in_sidebar_order(searches, &nav_items)
235        } else {
236            Vec::new()
237        };
238        if !searches.is_empty() {
239            page_routes.push(PageFn::new(
240                http::Method::GET,
241                route_path(&format!("{prefix}/{}", super::search::SEARCH_SEGMENT)),
242                super::search::search_page,
243            ));
244            endpoints.push(super::search::shard_path());
245        }
246        let root_redirect = match root {
247            Some(Root::Redirect(target)) => Some(target),
248            Some(Root::Home) | None => None,
249        };
250        let (served_paths, signed_roots) = super::files::served_paths(&served_dirs);
251        let state = Arc::new(PanelState {
252            realm: Arc::new(Realm {
253                prefix: prefix.clone(),
254                auth,
255                login_hint,
256                time_zone,
257                catalog,
258                notifications,
259                serves_tenants,
260                pages,
261                audit_log: urls.contains_key(&std::any::TypeId::of::<super::AuditLog>()),
262            }),
263            nav_items,
264            areas,
265            brand,
266            dark_mode,
267            environment,
268            shell_assets,
269            children,
270            mounts,
271            root_redirect: root_redirect.clone(),
272            user_menu,
273            uploads,
274            served_paths,
275            signed_roots,
276            urls,
277            mounted_pages,
278            endpoints,
279            searches,
280            limits,
281            commit_hooks,
282        });
283        let prefix_path = route_path(&prefix);
284        builder = builder.layer(limits.body_cap(prefix_path.clone()));
285        builder = builder
286            .layer(panel_headers.expect("a policy the header codec rejects fails the mount"));
287        // Registered last of the prefix's layers, so it runs first.
288        builder = builder.layer(PanelGate::new(Arc::clone(&state.realm)));
289        // The auth form routes carry their own cap scoped by path.
290        builder = crate::auth::mount::routes(builder, &state.realm);
291        // Only a panel with a language to switch to answers the language menu's post.
292        if state.realm.catalog.locales().len() > 1 {
293            builder = builder.route(RouteFn::new(
294                http::Method::POST,
295                route_path(&format!("{prefix}/{LANGUAGE_SEGMENT}")),
296                language_post,
297            ));
298        }
299        // Search engines skip the panel's pages, as the layout's metadata asks.
300        builder = builder.layout(
301            LayoutFn::new(prefix_path.clone(), layout.unwrap_or(Panel::layout_shell))
302                .with(Metadata::new().robots(Robots::new().index(false).follow(false))),
303        );
304        // A failed page load shows the error page inside the shell, and a path no route serves
305        // reaches the gate to fail there.
306        builder = builder
307            .page(PageFn::new(
308                http::Method::GET,
309                route_path(&format!("{prefix}/{}", error_page::ERROR_SEGMENT)),
310                error_page::error_page,
311            ))
312            .route(RouteFn::new(
313                Methods::Any,
314                route_path(&format!("{prefix}/{{*rest}}")),
315                error_page::unmatched,
316            ));
317        for served in served_dirs {
318            builder = builder
319                .layer(headers::ServedFileHeaders::new(&served.path, served.signed))
320                .serve_dir(route_path(&served.path), served.dir);
321        }
322        for page in page_routes {
323            builder = builder.page(page);
324        }
325        for route in routes {
326            builder = builder.route(route);
327        }
328        if root_redirect.is_some() {
329            builder = builder.route(RouteFn::new(
330                http::Method::GET,
331                prefix_path,
332                panel_root_redirect,
333            ));
334        }
335        // The builder lends no mutable access: copy the list of handles and register it again.
336        let mut panels = builder.context::<Panels>().clone();
337        panels.0.push(state);
338        Ok(builder.with(panels))
339    }
340}
341
342impl Registry {
343    /// Registers `registrations` and links their relations, returning the mounts and every error,
344    /// `configuration_errors` first.
345    fn register_all(
346        &mut self,
347        registrations: Vec<Box<dyn super::register::Registration>>,
348        audit_log: Option<super::audit::CustomizeAuditLog>,
349        configuration_errors: Vec<DeclarationError>,
350    ) -> (Arc<Mounts>, Vec<DeclarationError>) {
351        if let Some(customize) = audit_log {
352            self.audit_log = super::register::AuditLogList::Unplaced(customize);
353        }
354        for registration in registrations {
355            registration.register(self);
356        }
357        self.place_unplaced_audit_log();
358        self.link_relations();
359        self.check_root();
360        let mut errors = configuration_errors;
361        errors.append(&mut self.errors);
362        (Arc::new(std::mem::take(&mut self.mounts)), errors)
363    }
364
365    /// Checks every registered resource against `db`, whether the panel acts for tenants,
366    /// whether it installs an uploader, and the body cap its `limits` set.
367    fn check_all(
368        &self,
369        db: &Db,
370        mounts: &Arc<Mounts>,
371        serves_tenants: bool,
372        has_uploader: bool,
373        limits: &crate::Limits,
374        errors: &mut Vec<DeclarationError>,
375    ) {
376        let mut cx = validation_cx(db, mounts);
377        if serves_tenants {
378            cx = cx.with(crate::resource::ServesTenants);
379        }
380        for check in &self.page_checks {
381            check(&cx, errors);
382        }
383        for registered in &self.resources {
384            (registered.check)(&cx, errors);
385            // Without an uploader a file field would accept a file and keep only its name.
386            if !has_uploader {
387                errors.extend(registered.file_fields.iter().map(|field| DeclarationError {
388                    resource: Some(registered.name),
389                    site: Site::Form,
390                    kind: DeclarationErrorKind::FileFieldWithoutUploader {
391                        field: field.clone(),
392                    },
393                }));
394            }
395            // The body cap refuses a larger file first, so the field's own rule would never.
396            let cap = limits.body_bytes;
397            errors.extend(
398                registered
399                    .file_caps
400                    .iter()
401                    .filter(|(_, max)| *max >= cap)
402                    .map(|(field, max)| DeclarationError {
403                        resource: Some(registered.name),
404                        site: Site::Form,
405                        kind: DeclarationErrorKind::FieldRule {
406                            field: field.clone(),
407                            fault: crate::FieldRuleFault::AboveFormCap { max: *max, cap },
408                        },
409                    }),
410            );
411        }
412    }
413}
414
415/// Whether a panel with `auth` and `mounts` acts for tenants: it offers a tenant registration or
416/// settings page, or mounts a resource over tenant-owned rows.
417fn serves_tenants(auth: &crate::auth::AuthConfig, mounts: &Mounts) -> bool {
418    auth.tenant_registration_page().is_some()
419        || auth.tenant_editor().is_some()
420        || mounts.has_tenant_models()
421}
422
423/// What refuses a panel at `prefix` before its resources are checked.
424fn mount_errors(
425    builder: &RouterBuilder,
426    prefix: &str,
427    shell_assets: bool,
428    served_dirs: &[headers::ServedDir],
429) -> Vec<DeclarationError> {
430    let mut errors = Vec::new();
431    let mut refuse = |kind| errors.push(DeclarationError::panel(kind));
432    if shell_assets && builder.try_context::<AssetConfig>().is_none() {
433        refuse(DeclarationErrorKind::ShellAssetsWithoutBundle);
434    }
435    if under_prefix(RUNTIME_PREFIX, prefix) || under_prefix(prefix, RUNTIME_PREFIX) {
436        refuse(DeclarationErrorKind::PrefixOverlapsRuntime {
437            prefix: prefix.to_string(),
438        });
439    }
440    for (index, served) in served_dirs.iter().enumerate() {
441        let root = served_root(&served.path);
442        if served_dirs[..index]
443            .iter()
444            .any(|seen| served_root(&seen.path) == root)
445        {
446            refuse(DeclarationErrorKind::ServeDirTwice {
447                path: served.path.clone(),
448            });
449        }
450    }
451    if let Some(panels) = builder.try_context::<Panels>() {
452        for other in &panels.0 {
453            for ServedDir { path, .. } in served_dirs {
454                if other
455                    .served_paths
456                    .iter()
457                    .any(|served| served_root(served) == served_root(path))
458                {
459                    refuse(DeclarationErrorKind::ServeDirTaken {
460                        path: path.clone(),
461                        panel: other.realm.prefix.clone(),
462                    });
463                }
464            }
465            let other = &other.realm.prefix;
466            if under_prefix(other, prefix) || under_prefix(prefix, other) {
467                refuse(DeclarationErrorKind::PrefixOverlapsPanel {
468                    other: other.clone(),
469                });
470            }
471        }
472    }
473    errors
474}
475
476/// Installs what every panel on a router shares.
477fn install_shared(mut builder: RouterBuilder) -> RouterBuilder {
478    builder = builder
479        .layer(CookieLayer::new())
480        .layer(RuntimeGate::new())
481        .with(Panels::default())
482        .with(AuthHost(&PanelHost))
483        .with(MountScope(request_mounts))
484        .with(TenantSource(crate::auth::session_tenant))
485        .with(crate::values::ZoneSource(crate::auth::session_time_zone))
486        .with(crate::schema::FileHref(super::url::file))
487        .with(crate::page::PageBody(super::pages::declared_body))
488        .with(crate::i18n::CatalogSource(crate::auth::session_catalog))
489        .with(NotificationBus::new());
490    // The runtime layer has no path, so a page re-run reaches the panel's layers already rewritten
491    // to a `GET`. One the app installs before the panels sits inside this one and passes on what
492    // this one already rewrote.
493    builder = builder.layer(RuntimeLayer::new());
494    if builder.try_context::<PrefetchMode>().is_none() {
495        builder = builder.with(PrefetchMode::Never);
496    }
497    // The last pathless layer runs outermost, so this one sees the socket's handshake unless the
498    // app installs the runtime layer after the panels. `RuntimeGate` refuses those sockets' runs.
499    builder.layer(PagePanelStamp)
500}
501
502/// Redirects the panel root of a panel with no [`home`](Panel::home) page to the first declared
503/// resource's list.
504pub(crate) fn panel_root_redirect(cx: &Cx, _body: Body) -> RouteFuture<'_> {
505    Box::pin(async move {
506        // Re-checks the resolved user so a mis-mounted gate cannot leak the slug.
507        crate::auth::guard(cx)?;
508        let target = current(cx)
509            .and_then(|panel| panel.root_redirect.clone())
510            .ok_or_else(topcoat::router::error::not_found)?;
511        Err(redirect(target).into())
512    })
513}
514
515/// Reports whether `path` is a route pattern ending in a catch-all.
516pub(super) fn is_directory_pattern(path: &str) -> bool {
517    Path::from_str(path)
518        .ok()
519        .and_then(|parsed| parsed.segments().next_back())
520        .is_some_and(|segment| segment.as_catch_all().is_some())
521}
522
523/// Validates one path segment a panel derives routes from, refusing anything that cannot serve as a
524/// literal URL segment.
525pub(super) fn validate_route_segment(
526    item: &'static str,
527    segment: &str,
528) -> Result<(), DeclarationErrorKind> {
529    match segment_fault(segment) {
530        Some(fault) => Err(DeclarationErrorKind::InvalidSegment {
531            item,
532            segment: segment.to_string(),
533            fault,
534        }),
535        None => Ok(()),
536    }
537}
538
539/// The panel's catalog over `locales` and `translations`; `None`, with each fault pushed onto
540/// `errors`, when it does not build.
541fn build_catalog(
542    locales: &[topcoat::l10n::Locale],
543    translations: &[(topcoat::l10n::Locale, String)],
544    errors: &mut Vec<DeclarationError>,
545) -> Option<crate::i18n::Catalog> {
546    crate::i18n::Catalog::new(locales, translations)
547        .map_err(|faults| {
548            errors.extend(
549                faults
550                    .into_iter()
551                    .map(|fault| DeclarationError::panel(DeclarationErrorKind::Locale(fault))),
552            );
553        })
554        .ok()
555}
556
557/// Builds the context for the mount-time declaration checks and [`PanelHandle::context`] from `db`
558/// and
559/// the panel's `mounts`, with no request.
560fn validation_cx(db: &Db, mounts: &Arc<Mounts>) -> Cx {
561    let mut values = topcoat::context::ContextMap::new();
562    values.insert(db.clone());
563    values.insert(Arc::clone(mounts));
564    values.insert(AuthHost(&PanelHost));
565    values.insert(MountScope(|cx| {
566        topcoat::context::try_context::<Arc<Mounts>>(cx).map(|mounts| &**mounts)
567    }));
568    Cx::new().with_shared(Arc::new(values))
569}
570
571#[cfg(test)]
572mod tests;