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