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