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            banners,
151            auth,
152            mut endpoints,
153            notifications,
154            time_zone,
155            locales,
156            translations,
157            global_search,
158            limits,
159            commit_hooks,
160            audit_log,
161        } = self;
162        let (mounts, mut errors) =
163            registry.register_all(registrations, audit_log, configuration_errors);
164        let serves_tenants = serves_tenants(&auth, &mounts);
165        errors.extend(registry.unoffered_auth_pages(&auth));
166        errors.extend(mount_errors(
167            &builder,
168            &prefix,
169            shell_assets.is_some(),
170            &served_dirs,
171        ));
172        match &db {
173            Some(db) => {
174                registry.check_all(
175                    db,
176                    &mounts,
177                    serves_tenants,
178                    uploads.is_some(),
179                    &limits,
180                    &mut errors,
181                );
182                let toggles = registry.resources.iter().any(|resource| resource.toggles);
183                errors.extend(
184                    crate::auth::mount::declaration_errors(
185                        db,
186                        &auth,
187                        toggles,
188                        notifications,
189                        services,
190                    )
191                    .into_iter()
192                    .map(DeclarationError::panel),
193                );
194                errors.extend(super::audit::declaration_error(db, &registry.urls));
195            }
196            None => errors.push(DeclarationError::panel(DeclarationErrorKind::MissingDb)),
197        }
198        let catalog = build_catalog(&locales, &translations, &mut errors);
199        let panel_headers = match content_security_policy
200            .resolve(&content_security_policy_sources)
201            .and_then(|policy| {
202                headers::PanelHeaders::new(
203                    prefix.clone(),
204                    policy.as_deref(),
205                    strict_transport_security,
206                )
207            }) {
208            Ok(layer) => Some(layer),
209            Err(kind) => {
210                errors.push(DeclarationError::panel(kind));
211                None
212            }
213        };
214        if !errors.is_empty() {
215            return Err(MountError::new(&prefix, errors).into());
216        }
217        let catalog = Arc::new(catalog.expect("a catalog that failed to build fails the mount"));
218        if builder.try_context::<Panels>().is_none() {
219            builder = install_shared(builder);
220        }
221        let pages = super::area::settings_paths(&prefix, &registry.auth_pages);
222        let Registry {
223            urls,
224            mounted_pages,
225            nav_items,
226            areas,
227            searches,
228            pages: mut page_routes,
229            routes,
230            root,
231            children,
232            ..
233        } = registry;
234        let searches = if global_search {
235            super::search::in_sidebar_order(searches, &nav_items)
236        } else {
237            Vec::new()
238        };
239        if !searches.is_empty() {
240            page_routes.push(PageFn::new(
241                http::Method::GET,
242                route_path(&format!("{prefix}/{}", super::search::SEARCH_SEGMENT)),
243                super::search::search_page,
244            ));
245            endpoints.push(super::search::shard_path());
246        }
247        let root_redirect = match root {
248            Some(Root::Redirect(target)) => Some(target),
249            Some(Root::Home) | None => None,
250        };
251        let (served_paths, signed_roots) = super::files::served_paths(&served_dirs);
252        let state = Arc::new(PanelState {
253            realm: Arc::new(Realm {
254                prefix: prefix.clone(),
255                auth,
256                login_hint,
257                time_zone,
258                catalog,
259                notifications,
260                serves_tenants,
261                pages,
262                audit_log: urls.contains_key(&std::any::TypeId::of::<super::AuditLog>()),
263            }),
264            nav_items,
265            areas,
266            brand,
267            dark_mode,
268            environment,
269            shell_assets,
270            children,
271            mounts,
272            root_redirect: root_redirect.clone(),
273            user_menu,
274            banners,
275            uploads,
276            served_paths,
277            signed_roots,
278            urls,
279            mounted_pages,
280            endpoints,
281            searches,
282            limits,
283            commit_hooks,
284        });
285        let prefix_path = route_path(&prefix);
286        builder = builder.layer(limits.body_cap(prefix_path.clone()));
287        builder = builder
288            .layer(panel_headers.expect("a policy the header codec rejects fails the mount"));
289        // Registered last of the prefix's layers, so it runs first.
290        builder = builder.layer(PanelGate::new(Arc::clone(&state.realm)));
291        // The auth form routes carry their own cap scoped by path.
292        builder = crate::auth::mount::routes(builder, &state.realm);
293        // Only a panel with a language to switch to answers the language menu's post.
294        if state.realm.catalog.locales().len() > 1 {
295            builder = builder.route(RouteFn::new(
296                http::Method::POST,
297                route_path(&format!("{prefix}/{LANGUAGE_SEGMENT}")),
298                language_post,
299            ));
300        }
301        // Search engines skip the panel's pages, as the layout's metadata asks.
302        builder = builder.layout(
303            LayoutFn::new(prefix_path.clone(), layout.unwrap_or(Panel::layout_shell))
304                .with(Metadata::new().robots(Robots::new().index(false).follow(false))),
305        );
306        // A failed page load shows the error page inside the shell, and a path no route serves
307        // reaches the gate to fail there.
308        builder = builder
309            .page(PageFn::new(
310                http::Method::GET,
311                route_path(&format!("{prefix}/{}", error_page::ERROR_SEGMENT)),
312                error_page::error_page,
313            ))
314            .route(RouteFn::new(
315                Methods::Any,
316                route_path(&format!("{prefix}/{{*rest}}")),
317                error_page::unmatched,
318            ));
319        for served in served_dirs {
320            builder = builder
321                .layer(headers::ServedFileHeaders::new(&served.path, served.signed))
322                .serve_dir(route_path(&served.path), served.dir);
323        }
324        for page in page_routes {
325            builder = builder.page(page);
326        }
327        for route in routes {
328            builder = builder.route(route);
329        }
330        if root_redirect.is_some() {
331            builder = builder.route(RouteFn::new(
332                http::Method::GET,
333                prefix_path,
334                panel_root_redirect,
335            ));
336        }
337        // The builder lends no mutable access: copy the list of handles and register it again.
338        let mut panels = builder.context::<Panels>().clone();
339        panels.0.push(state);
340        Ok(builder.with(panels))
341    }
342}
343
344impl Registry {
345    /// Registers `registrations` and links their relations, returning the mounts and every error,
346    /// `configuration_errors` first.
347    fn register_all(
348        &mut self,
349        registrations: Vec<Box<dyn super::register::Registration>>,
350        audit_log: Option<super::audit::CustomizeAuditLog>,
351        configuration_errors: Vec<DeclarationError>,
352    ) -> (Arc<Mounts>, Vec<DeclarationError>) {
353        if let Some(customize) = audit_log {
354            self.audit_log = super::register::AuditLogList::Unplaced(customize);
355        }
356        for registration in registrations {
357            registration.register(self);
358        }
359        self.place_unplaced_audit_log();
360        self.link_relations();
361        self.check_root();
362        let mut errors = configuration_errors;
363        errors.append(&mut self.errors);
364        (Arc::new(std::mem::take(&mut self.mounts)), errors)
365    }
366
367    /// Checks every registered resource against `db`, whether the panel acts for tenants,
368    /// whether it installs an uploader, and the body cap its `limits` set.
369    fn check_all(
370        &self,
371        db: &Db,
372        mounts: &Arc<Mounts>,
373        serves_tenants: bool,
374        has_uploader: bool,
375        limits: &crate::Limits,
376        errors: &mut Vec<DeclarationError>,
377    ) {
378        let mut cx = validation_cx(db, mounts);
379        if serves_tenants {
380            cx = cx.with(crate::resource::ServesTenants);
381        }
382        for check in &self.page_checks {
383            check(&cx, errors);
384        }
385        for registered in &self.resources {
386            (registered.check)(&cx, errors);
387            // Without an uploader a file field would accept a file and keep only its name.
388            if !has_uploader {
389                errors.extend(registered.file_fields.iter().map(|field| DeclarationError {
390                    resource: Some(registered.name),
391                    site: Site::Form,
392                    kind: DeclarationErrorKind::FileFieldWithoutUploader {
393                        field: field.clone(),
394                    },
395                }));
396            }
397            // The body cap refuses a larger file first, so the field's own rule would never.
398            let cap = limits.body_bytes;
399            errors.extend(
400                registered
401                    .file_caps
402                    .iter()
403                    .filter(|(_, max)| *max >= cap)
404                    .map(|(field, max)| DeclarationError {
405                        resource: Some(registered.name),
406                        site: Site::Form,
407                        kind: DeclarationErrorKind::FieldRule {
408                            field: field.clone(),
409                            fault: crate::FieldRuleFault::AboveFormCap { max: *max, cap },
410                        },
411                    }),
412            );
413        }
414    }
415}
416
417/// Whether a panel with `auth` and `mounts` acts for tenants: it offers a tenant registration or
418/// settings page, or mounts a resource over tenant-owned rows.
419fn serves_tenants(auth: &crate::auth::AuthConfig, mounts: &Mounts) -> bool {
420    auth.tenant_registration_page().is_some()
421        || auth.tenant_editor().is_some()
422        || mounts.has_tenant_models()
423}
424
425/// What refuses a panel at `prefix` before its resources are checked.
426fn mount_errors(
427    builder: &RouterBuilder,
428    prefix: &str,
429    shell_assets: bool,
430    served_dirs: &[headers::ServedDir],
431) -> Vec<DeclarationError> {
432    let mut errors = Vec::new();
433    let mut refuse = |kind| errors.push(DeclarationError::panel(kind));
434    if shell_assets && builder.try_context::<AssetConfig>().is_none() {
435        refuse(DeclarationErrorKind::ShellAssetsWithoutBundle);
436    }
437    if under_prefix(RUNTIME_PREFIX, prefix) || under_prefix(prefix, RUNTIME_PREFIX) {
438        refuse(DeclarationErrorKind::PrefixOverlapsRuntime {
439            prefix: prefix.to_string(),
440        });
441    }
442    for (index, served) in served_dirs.iter().enumerate() {
443        let root = served_root(&served.path);
444        if served_dirs[..index]
445            .iter()
446            .any(|seen| served_root(&seen.path) == root)
447        {
448            refuse(DeclarationErrorKind::ServeDirTwice {
449                path: served.path.clone(),
450            });
451        }
452    }
453    if let Some(panels) = builder.try_context::<Panels>() {
454        for other in &panels.0 {
455            for ServedDir { path, .. } in served_dirs {
456                if other
457                    .served_paths
458                    .iter()
459                    .any(|served| served_root(served) == served_root(path))
460                {
461                    refuse(DeclarationErrorKind::ServeDirTaken {
462                        path: path.clone(),
463                        panel: other.realm.prefix.clone(),
464                    });
465                }
466            }
467            let other = &other.realm.prefix;
468            if under_prefix(other, prefix) || under_prefix(prefix, other) {
469                refuse(DeclarationErrorKind::PrefixOverlapsPanel {
470                    other: other.clone(),
471                });
472            }
473        }
474    }
475    errors
476}
477
478/// Installs what every panel on a router shares.
479fn install_shared(mut builder: RouterBuilder) -> RouterBuilder {
480    builder = builder
481        .layer(CookieLayer::new())
482        .layer(RuntimeGate::new())
483        .with(Panels::default())
484        .with(AuthHost(&PanelHost))
485        .with(MountScope(request_mounts))
486        .with(TenantSource(crate::auth::session_tenant))
487        .with(crate::values::ZoneSource(crate::auth::session_time_zone))
488        .with(crate::schema::FileHref(super::url::file))
489        .with(crate::page::PageBody(super::pages::declared_body))
490        .with(crate::i18n::CatalogSource(crate::auth::session_catalog))
491        .with(NotificationBus::new());
492    // The runtime layer has no path, so a page re-run reaches the panel's layers already rewritten
493    // to a `GET`. One the app installs before the panels sits inside this one and passes on what
494    // this one already rewrote.
495    builder = builder.layer(RuntimeLayer::new());
496    if builder.try_context::<PrefetchMode>().is_none() {
497        builder = builder.with(PrefetchMode::Never);
498    }
499    // The last pathless layer runs outermost, so this one sees the socket's handshake unless the
500    // app installs the runtime layer after the panels. `RuntimeGate` refuses those sockets' runs.
501    builder.layer(PagePanelStamp)
502}
503
504/// Redirects the panel root of a panel with no [`home`](Panel::home) page to the first declared
505/// resource's list.
506pub(crate) fn panel_root_redirect(cx: &Cx, _body: Body) -> RouteFuture<'_> {
507    Box::pin(async move {
508        // Re-checks the resolved user so a mis-mounted gate cannot leak the slug.
509        crate::auth::guard(cx)?;
510        let target = current(cx)
511            .and_then(|panel| panel.root_redirect.clone())
512            .ok_or_else(topcoat::router::error::not_found)?;
513        Err(redirect(target).into())
514    })
515}
516
517/// Reports whether `path` is a route pattern ending in a catch-all.
518pub(super) fn is_directory_pattern(path: &str) -> bool {
519    Path::from_str(path)
520        .ok()
521        .and_then(|parsed| parsed.segments().next_back())
522        .is_some_and(|segment| segment.as_catch_all().is_some())
523}
524
525/// Validates one path segment a panel derives routes from, refusing anything that cannot serve as a
526/// literal URL segment.
527pub(super) fn validate_route_segment(
528    item: &'static str,
529    segment: &str,
530) -> Result<(), DeclarationErrorKind> {
531    match segment_fault(segment) {
532        Some(fault) => Err(DeclarationErrorKind::InvalidSegment {
533            item,
534            segment: segment.to_string(),
535            fault,
536        }),
537        None => Ok(()),
538    }
539}
540
541/// The panel's catalog over `locales` and `translations`; `None`, with each fault pushed onto
542/// `errors`, when it does not build.
543fn build_catalog(
544    locales: &[topcoat::l10n::Locale],
545    translations: &[(topcoat::l10n::Locale, String)],
546    errors: &mut Vec<DeclarationError>,
547) -> Option<crate::i18n::Catalog> {
548    crate::i18n::Catalog::new(locales, translations)
549        .map_err(|faults| {
550            errors.extend(
551                faults
552                    .into_iter()
553                    .map(|fault| DeclarationError::panel(DeclarationErrorKind::Locale(fault))),
554            );
555        })
556        .ok()
557}
558
559/// Builds the context for the mount-time declaration checks and [`PanelHandle::context`] from `db`
560/// and
561/// the panel's `mounts`, with no request.
562fn validation_cx(db: &Db, mounts: &Arc<Mounts>) -> Cx {
563    let mut values = topcoat::context::ContextMap::new();
564    values.insert(db.clone());
565    values.insert(Arc::clone(mounts));
566    values.insert(AuthHost(&PanelHost));
567    values.insert(MountScope(|cx| {
568        topcoat::context::try_context::<Arc<Mounts>>(cx).map(|mounts| &**mounts)
569    }));
570    Cx::new().with_shared(Arc::new(values))
571}
572
573#[cfg(test)]
574mod tests;