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