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