Skip to main content

laterite_admin/
routes.rs

1//! Routes a module mounts itself: admin screens, and public endpoints.
2//!
3//! A [`Resource`](crate::Resource) covers list and form screens as data. A
4//! [`Screen`] is the substrate underneath: any routes a module wants, mounted
5//! inside the admin with the same session, permission, CSRF and error handling a
6//! resource gets. Reach for it only when descriptors cannot express the screen.
7//!
8//! A [`PublicRoute`] is the same idea outside the admin, for the endpoints that
9//! have to live at a literal path: `/robots.txt`, a sitemap, a feed, a webhook.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use axum::Router;
15use laterite_auth::{AuditEntry, AuthService, AuthenticatedUser};
16use laterite_core::{Db, Events, Registry, Text};
17
18use crate::form::{FormField, FormHandle, Forms, ValueForm};
19use crate::list::ImportLink;
20
21/// What a contributed route is given at boot.
22///
23/// Deliberately narrow: the admin's own router state stays private, so its
24/// composition can change without breaking a screen. Fields are read through
25/// accessors, so this can gain context after 1.0; `new` cannot, and extra context
26/// would arrive as a builder.
27#[derive(Clone)]
28pub struct RouteCtx {
29    db: Db,
30    auth: AuthService,
31    parameters: laterite_core::parameters::Parameters,
32    cache: laterite_core::Cache,
33    upload_extensions: Arc<Vec<String>>,
34    field_types: Arc<crate::field::FieldRegistry>,
35    overrides: Arc<dyn crate::field::OverrideResolver>,
36    crypt: Arc<laterite_core::Crypt>,
37    storage_dir: Arc<str>,
38    base_path: Arc<str>,
39    admin_path: Arc<str>,
40    base_url: Arc<str>,
41    plugin_defined: Arc<Registry>,
42    events: Events,
43    forms: Forms,
44}
45
46impl RouteCtx {
47    #[allow(clippy::too_many_arguments)]
48    pub(crate) fn new(
49        db: Db,
50        auth: AuthService,
51        parameters: laterite_core::parameters::Parameters,
52        upload_extensions: Arc<Vec<String>>,
53        field_types: Arc<crate::field::FieldRegistry>,
54        overrides: Arc<dyn crate::field::OverrideResolver>,
55        crypt: Arc<laterite_core::Crypt>,
56        storage_dir: &str,
57        base_path: &str,
58        admin_path: &str,
59        base_url: &str,
60        plugin_defined: Arc<Registry>,
61        events: Events,
62        forms: Forms,
63    ) -> Self {
64        Self {
65            db,
66            auth,
67            cache: parameters.cache().clone(),
68            parameters,
69            upload_extensions,
70            field_types,
71            overrides,
72            crypt,
73            storage_dir: Arc::from(storage_dir.trim_end_matches('/')),
74            base_path: Arc::from(base_path),
75            admin_path: Arc::from(admin_path),
76            base_url: Arc::from(base_url),
77            plugin_defined,
78            events,
79            forms,
80        }
81    }
82
83    /// Appends one audit entry for a mutation `user` just performed through a
84    /// module's own screen. A failed write is logged and never fails the
85    /// operation it records.
86    pub async fn audit(
87        &self,
88        user: &AuthenticatedUser,
89        action: &str,
90        target_type: Option<&str>,
91        target_id: Option<&str>,
92        target_label: Option<&str>,
93        detail: Option<&str>,
94    ) {
95        let actor = laterite_core::Actor::user(user.user.id, user.user.username.clone());
96        let mut entry = AuditEntry::new(&actor, action);
97        if let (Some(kind), Some(id)) = (target_type, target_id) {
98            entry = entry.target(kind, id, target_label);
99        }
100        if let Some(detail) = detail {
101            entry = entry.detail(detail);
102        }
103        if let Err(e) = self.auth.record_audit(entry).await {
104            tracing::error!(action, error = %e, "failed to write audit log entry");
105        }
106    }
107
108    /// A form over a JSON object from `fields`, rendered and collected through
109    /// the registered field types; `scope` names what an override is scoped to.
110    /// Refused, naming the field, when a type is unregistered or an option
111    /// malformed: the fields may come from data edited at runtime.
112    pub fn value_form(&self, fields: &[FormField], scope: &str) -> Result<ValueForm, Text> {
113        ValueForm::new(
114            fields,
115            self.field_types.clone(),
116            self.overrides.clone(),
117            scope,
118        )
119    }
120
121    /// The operator a request's session cookie belongs to, when it carries a
122    /// live one: how a public route tells an operator from a visitor.
123    pub async fn operator(&self, headers: &axum::http::HeaderMap) -> Option<AuthenticatedUser> {
124        let token = headers
125            .get_all(axum::http::header::COOKIE)
126            .iter()
127            .filter_map(|v| v.to_str().ok())
128            .flat_map(|line| line.split(';'))
129            .filter_map(|pair| pair.trim().split_once('='))
130            .find(|(name, _)| *name == crate::SESSION_COOKIE)
131            .map(|(_, value)| value.trim().to_string())?;
132        self.auth.verify_session(&token).await.ok()
133    }
134
135    /// Counts a URL the site could not serve in the request log, as the panel
136    /// does for its own.
137    pub async fn record_unserved(&self, method: &str, path: &str, status: u16) {
138        crate::requestlog::record(&self.db, method, path, status).await;
139    }
140
141    /// The file extensions an upload may carry (`backend.upload_extensions`,
142    /// else [`laterite_media::sanitize::DEFAULT_EXTENSIONS`]), lowercased. A
143    /// screen that takes files checks a name with
144    /// [`laterite_media::sanitize::extension_allowed`] before storing it.
145    pub fn upload_extensions(&self) -> &[String] {
146        &self.upload_extensions
147    }
148
149    /// The deployment's storage directory (`backend.storage`), where a module
150    /// keeps files when the application registered no disk for it.
151    pub fn storage_dir(&self) -> &str {
152        &self.storage_dir
153    }
154
155    /// Signs `message` under the application key for the `url` purpose: a
156    /// token in a link the route mints (a private file's URL). See
157    /// [`laterite_core::crypt`]; for a token with an expiry or a sealed value,
158    /// use [`crypt`](Self::crypt).
159    pub fn sign(&self, message: &[u8]) -> String {
160        self.crypt.sign("url", message)
161    }
162
163    /// Whether `signature` is [`sign`](Self::sign) of `message`, compared in
164    /// constant time.
165    pub fn verify_signature(&self, message: &[u8], signature: &str) -> bool {
166        self.crypt.verify("url", message, signature)
167    }
168
169    /// The application's encryption and signing, under `app.key`.
170    pub fn crypt(&self) -> &laterite_core::Crypt {
171        &self.crypt
172    }
173
174    pub fn db(&self) -> &Db {
175        &self.db
176    }
177
178    /// The parameters store, read through the cache. See
179    /// [`laterite_core::parameters`].
180    pub fn parameters(&self) -> &laterite_core::parameters::Parameters {
181        &self.parameters
182    }
183
184    /// The application's cache. See [`laterite_core::cache`].
185    pub fn cache(&self) -> &laterite_core::Cache {
186        &self.cache
187    }
188
189    /// The settings store, writing through the cache. See
190    /// [`crate::settings::Settings`].
191    pub fn settings(&self) -> crate::settings::Settings {
192        crate::settings::Settings::new(self.db.clone(), self.cache.clone())
193    }
194
195    /// A resource's form, to write its records from code: an importer, a sync,
196    /// anything that saves what an operator could have typed.
197    ///
198    /// `path` is the resource's path as its descriptor declares it, relative to
199    /// the admin root (`/products`). `None` when no resource mounts there or the
200    /// one that does has no form. The handle is `Clone` and owns what it needs,
201    /// so a detached task may keep it.
202    pub fn form(&self, path: &str) -> Option<FormHandle> {
203        self.forms
204            .get(path)
205            .map(|form| FormHandle::new(self.db.clone(), self.cache.clone(), form.clone()))
206    }
207
208    /// The application's event bus, for a route that announces a fact:
209    /// `ctx.events().emit(&Published { article_id }).await`. Clone it into the
210    /// route's state.
211    pub fn events(&self) -> &Events {
212        &self.events
213    }
214
215    /// The site's own origin, without a trailing slash: the configured
216    /// `app.url`, falling back to the bind address when none is declared.
217    ///
218    /// For the absolute URLs a route has to emit rather than link: a `<loc>` in a
219    /// sitemap, a canonical URL, an entry in a feed. The request's `Host` header
220    /// is not a substitute, because behind a proxy it is whatever the proxy sent.
221    pub fn base_url(&self) -> &str {
222        &self.base_url
223    }
224
225    /// The contributions of type `T` that the framework itself does not consume.
226    ///
227    /// This is how one module reads another's contributions. A module declaring
228    /// its own kind of extension point defines a type, other modules contribute
229    /// it from their `register`, and the route reads them here: the framework
230    /// never learns the type, and registration order does not matter because
231    /// everything is collected before any route runs.
232    pub fn contributions<T: Send + Sync + 'static>(&self) -> Vec<&T> {
233        self.plugin_defined.items::<T>()
234    }
235
236    /// Where this screen actually mounted, after the module namespace, any base
237    /// it declared, the deployment's overrides and the admin mount.
238    pub fn base_path(&self) -> &str {
239        &self.base_path
240    }
241
242    /// Where the admin panel is mounted, after `backend.path`. Ask rather than
243    /// assuming `/admin`: an operator may have moved it, and a module building a
244    /// link into the panel, or excluding it from a sitemap, needs the real one.
245    pub fn admin_path(&self) -> &str {
246        &self.admin_path
247    }
248
249    /// An absolute admin URL under this screen. Build every self-link this way:
250    /// a deployment can move the screen, so a hardcoded path would break.
251    pub fn url(&self, relative: &str) -> String {
252        join(&self.base_path, relative)
253    }
254
255    /// A context built by hand, for testing a route without booting the framework.
256    ///
257    /// `mount` takes a `RouteCtx`, and the framework's own constructor is private,
258    /// so without this a plugin could only exercise its routes through a full boot.
259    /// With it, a route is an ordinary `tower` service:
260    ///
261    /// ```ignore
262    /// let ctx = RouteCtx::builder(db).admin_path("/backoffice").build();
263    /// let response = MyRoute.mount(&ctx).oneshot(request).await?;
264    /// ```
265    ///
266    /// This is also how the context grows after 1.0: a new field gets a builder
267    /// method and a default, which `new` could not have offered.
268    pub fn builder(db: Db) -> RouteCtxBuilder {
269        RouteCtxBuilder {
270            db,
271            auth: None,
272            base_path: "/".to_string(),
273            admin_path: "/admin".to_string(),
274            base_url: "http://localhost".to_string(),
275            plugin_defined: None,
276            events: None,
277            cache: None,
278        }
279    }
280}
281
282/// Builds a [`RouteCtx`]. Every field has a default, so a test names only what it
283/// asserts on.
284pub struct RouteCtxBuilder {
285    db: Db,
286    auth: Option<AuthService>,
287    base_path: String,
288    admin_path: String,
289    base_url: String,
290    plugin_defined: Option<Arc<Registry>>,
291    events: Option<Events>,
292    cache: Option<laterite_core::Cache>,
293}
294
295impl RouteCtxBuilder {
296    /// The cache `RouteCtx::cache` hands out. Defaults to one process's own.
297    pub fn cache(mut self, cache: laterite_core::Cache) -> Self {
298        self.cache = Some(cache);
299        self
300    }
301
302    /// Where the screen mounted. Defaults to `/`.
303    pub fn base_path(mut self, path: impl Into<String>) -> Self {
304        self.base_path = path.into();
305        self
306    }
307
308    /// Where the panel mounted. Defaults to `/admin`, which is what a route
309    /// excluding the panel should *not* be allowed to assume: set it to something
310    /// else in a test that checks the exclusion is read rather than hardcoded.
311    pub fn admin_path(mut self, path: impl Into<String>) -> Self {
312        self.admin_path = path.into();
313        self
314    }
315
316    /// The site's origin. Defaults to `http://localhost`.
317    pub fn base_url(mut self, url: impl Into<String>) -> Self {
318        self.base_url = url.into();
319        self
320    }
321
322    /// The contributions `RouteCtx::contributions` will read. Defaults to empty.
323    pub fn contributions(mut self, registry: Arc<Registry>) -> Self {
324        self.plugin_defined = Some(registry);
325        self
326    }
327
328    /// The bus `RouteCtx::events` will hand out. Defaults to one nothing
329    /// listens on; pass one built with a listener to assert on what a route
330    /// announces.
331    pub fn events(mut self, events: Events) -> Self {
332        self.events = Some(events);
333        self
334    }
335
336    /// The auth service [`RouteCtx::audit`] writes through. Without one, a
337    /// default service over the same database.
338    pub fn auth(mut self, auth: AuthService) -> Self {
339        self.auth = Some(auth);
340        self
341    }
342
343    pub fn build(self) -> RouteCtx {
344        let events = self.events.unwrap_or_else(|| Events::new(self.db.clone()));
345        let auth = self.auth.unwrap_or_else(|| {
346            AuthService::new(self.db.clone(), laterite_auth::AuthConfig::default())
347        });
348        RouteCtx::new(
349            self.db.clone(),
350            auth,
351            laterite_core::parameters::Parameters::with_cache(
352                self.db,
353                self.cache.unwrap_or_else(laterite_core::Cache::local),
354            ),
355            Arc::new(crate::upload_extensions(&[])),
356            Arc::new(crate::field::builtin_registry()),
357            Arc::new(crate::field::NoOverrides),
358            Arc::new(laterite_core::Crypt::ephemeral()),
359            "storage",
360            &self.base_path,
361            &self.admin_path,
362            &self.base_url,
363            self.plugin_defined
364                .unwrap_or_else(|| Arc::new(Registry::new())),
365            events,
366            Arc::new(HashMap::new()),
367        )
368    }
369}
370
371fn join(base: &str, relative: &str) -> String {
372    let base = base.trim_end_matches('/');
373    let relative = relative.trim_start_matches('/');
374    if relative.is_empty() {
375        base.to_string()
376    } else {
377        format!("{base}/{relative}")
378    }
379}
380
381/// A module's own admin screen.
382pub trait Screen: Send + Sync + 'static {
383    /// The routes this screen serves, relative to its own base. Handlers capture
384    /// what they need from `ctx`, so the router carries no framework state.
385    fn mount(&self, ctx: &RouteCtx) -> Router;
386}
387
388/// A registered screen: where it mounts, what it is called, and who may reach it.
389///
390/// Built with [`ScreenReg::new`] plus its builder methods. Non-exhaustive so the
391/// framework can learn something new about a screen after 1.0 without a major bump:
392/// anything it needs to know belongs here rather than as another trait method, which
393/// is also what keeps the code half replaceable at the plugin boundary.
394#[non_exhaustive]
395pub struct ScreenReg {
396    /// Path relative to the module's admin base, starting with a slash.
397    pub base_path: String,
398    /// Menu label, localized at render. `None` mounts it without a nav entry.
399    pub nav_label: Option<Text>,
400    /// Where the entry sits: under a section, with an icon, at a position.
401    pub menu: crate::nav::Placement,
402    /// The permission every route under this screen requires. Required, because
403    /// an unguarded screen exposes whatever it serves to any signed-in operator.
404    pub permission: String,
405    pub screen: Arc<dyn Screen>,
406}
407
408impl ScreenReg {
409    pub fn new(
410        base_path: impl Into<String>,
411        permission: impl Into<String>,
412        screen: Arc<dyn Screen>,
413    ) -> Self {
414        Self {
415            base_path: base_path.into(),
416            nav_label: None,
417            menu: crate::nav::Placement::default(),
418            permission: permission.into(),
419            screen,
420        }
421    }
422
423    /// Shows this screen in the menu under `label`.
424    pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
425        self.nav_label = Some(label.into());
426        self
427    }
428
429    /// Where the menu entry sits. See [`Placement`](crate::nav::Placement).
430    pub fn menu(mut self, placement: crate::nav::Placement) -> Self {
431        self.menu = placement;
432        self
433    }
434}
435
436/// A module's own route outside the admin.
437///
438/// Public by definition: no permission gate and no admin chrome, though the
439/// styled error pages still apply. A route that accepts a POST declares its own
440/// stance on CSRF, because the admin's blanket protection assumes a session.
441pub trait PublicRoute: Send + Sync + 'static {
442    /// The routes this endpoint serves, relative to its declared path.
443    fn mount(&self, ctx: &RouteCtx) -> Router;
444}
445
446/// A registered public route.
447///
448/// The path is literal and never namespaced: `/robots.txt` has to be exactly
449/// that. Collisions are therefore likelier than in the admin, so two claims on
450/// one path, or a claim on the admin mount, abort the boot.
451/// Built with [`PublicRouteReg::new`]. Non-exhaustive for the same reason as
452/// [`ScreenReg`].
453#[non_exhaustive]
454pub struct PublicRouteReg {
455    pub path: String,
456    pub route: Arc<dyn PublicRoute>,
457}
458
459impl PublicRouteReg {
460    pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
461        Self {
462            path: path.into(),
463            route,
464        }
465    }
466
467    /// The site itself: the route every URL reaches that no admin route and no
468    /// other public route serves, in place of the panel's own 404. One module
469    /// may register it; a second aborts the boot as a clash.
470    pub fn site(route: Arc<dyn PublicRoute>) -> Self {
471        Self::new("/", route)
472    }
473
474    /// Whether this registration is the site (see [`PublicRouteReg::site`]).
475    pub fn is_site(&self) -> bool {
476        self.path == "/"
477    }
478}
479
480/// A feature a module mounts under every resource whose descriptor declares
481/// its key: the routes behind `import: true`, say.
482///
483/// The admin owns the key and the button; the module owns the routes. A
484/// descriptor declaring a key no module serves aborts boot naming it, so the
485/// admin never depends on the module.
486pub trait ResourceFeature: Send + Sync + 'static {
487    /// The routes, relative to `{resource}/{key}`, which is `ctx.base_path()`.
488    fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router;
489}
490
491/// A registered feature, keyed by the descriptor key it serves. Mounted under
492/// every resource declaring that key, behind the key's permission or, when the
493/// key names none, the resource's.
494#[non_exhaustive]
495pub struct ResourceFeatureReg {
496    pub key: String,
497    pub feature: Arc<dyn ResourceFeature>,
498}
499
500impl ResourceFeatureReg {
501    pub fn new(key: impl Into<String>, feature: Arc<dyn ResourceFeature>) -> Self {
502        Self {
503            key: key.into(),
504            feature,
505        }
506    }
507}
508
509/// The resource a feature mounts under, as the feature sees it.
510#[derive(Clone)]
511#[non_exhaustive]
512pub struct ResourceInfo {
513    /// The resource's path as its descriptor declares it, relative to the
514    /// admin root (`/products`).
515    pub path: String,
516    /// Where its list mounted (`/admin/products`).
517    pub base_path: String,
518    /// The resource's menu label.
519    pub label: Text,
520    /// The permission the feature's routes are gated by.
521    pub permission: String,
522    /// The resource's form, to write its records through.
523    pub form: FormHandle,
524}
525
526impl ResourceInfo {
527    pub(crate) fn new(
528        path: String,
529        base_path: String,
530        label: Text,
531        permission: String,
532        form: FormHandle,
533    ) -> Self {
534        Self {
535            path,
536            base_path,
537            label,
538            permission,
539            form,
540        }
541    }
542}
543
544/// The link a list shows to a feature mounted under it.
545pub(crate) fn feature_link(base_path: &str, key: &str, permission: &str) -> ImportLink {
546    ImportLink {
547        href: format!("{base_path}/{key}"),
548        permission: permission.to_string(),
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn a_self_link_builds_from_where_the_screen_actually_mounted() {
558        let base = "/admin/rainmill/location/import";
559        assert_eq!(
560            join(base, "/step2"),
561            "/admin/rainmill/location/import/step2"
562        );
563        assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
564        assert_eq!(join(base, ""), "/admin/rainmill/location/import");
565    }
566
567    #[test]
568    fn a_moved_screen_moves_its_links_with_it() {
569        assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
570    }
571
572    #[test]
573    fn a_registration_defaults_to_no_menu_entry() {
574        struct Noop;
575        impl Screen for Noop {
576            fn mount(&self, _ctx: &RouteCtx) -> Router {
577                Router::new()
578            }
579        }
580        let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
581        assert!(reg.nav_label.is_none());
582        assert!(reg.in_menu("Import").nav_label.is_some());
583    }
584}
585
586/// Bytes from lowercase or uppercase hex; `None` on an odd length or a stray
587/// character.
588pub(crate) fn unhex(hex: &str) -> Option<Vec<u8>> {
589    if !hex.len().is_multiple_of(2) || !hex.is_ascii() {
590        return None;
591    }
592    (0..hex.len())
593        .step_by(2)
594        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
595        .collect()
596}
597
598#[cfg(test)]
599mod signing_tests {
600    use super::*;
601
602    #[tokio::test]
603    async fn a_route_signs_for_the_url_purpose_under_its_own_key() {
604        let (db, _guard) = laterite_core::testing::connect_test(&[]).await;
605        let ctx = RouteCtx::builder(db.clone()).build();
606        let sig = ctx.sign(b"media:abc:99");
607        assert!(ctx.verify_signature(b"media:abc:99", &sig));
608        assert!(!ctx.verify_signature(b"media:abc:98", &sig));
609        assert!(!ctx.crypt().verify("token", b"media:abc:99", &sig));
610        let other = RouteCtx::builder(db).build();
611        assert!(!other.verify_signature(b"media:abc:99", &sig));
612    }
613}