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