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 crypt: Arc<laterite_core::Crypt>,
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 crypt: Arc<laterite_core::Crypt>,
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 crypt,
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` under the application key for the `url` purpose: a
154 /// token in a link the route mints (a private file's URL). See
155 /// [`laterite_core::crypt`]; for a token with an expiry or a sealed value,
156 /// use [`crypt`](Self::crypt).
157 pub fn sign(&self, message: &[u8]) -> String {
158 self.crypt.sign("url", message)
159 }
160
161 /// Whether `signature` is [`sign`](Self::sign) of `message`, compared in
162 /// constant time.
163 pub fn verify_signature(&self, message: &[u8], signature: &str) -> bool {
164 self.crypt.verify("url", message, signature)
165 }
166
167 /// The application's encryption and signing, under `app.key`.
168 pub fn crypt(&self) -> &laterite_core::Crypt {
169 &self.crypt
170 }
171
172 pub fn db(&self) -> &Db {
173 &self.db
174 }
175
176 /// The parameters store, cached per process. See
177 /// [`laterite_core::parameters`].
178 pub fn parameters(&self) -> &laterite_core::parameters::Parameters {
179 &self.parameters
180 }
181
182 /// A resource's form, to write its records from code: an importer, a sync,
183 /// anything that saves what an operator could have typed.
184 ///
185 /// `path` is the resource's path as its descriptor declares it, relative to
186 /// the admin root (`/products`). `None` when no resource mounts there or the
187 /// one that does has no form. The handle is `Clone` and owns what it needs,
188 /// so a detached task may keep it.
189 pub fn form(&self, path: &str) -> Option<FormHandle> {
190 self.forms
191 .get(path)
192 .map(|form| FormHandle::new(self.db.clone(), form.clone()))
193 }
194
195 /// The application's event bus, for a route that announces a fact:
196 /// `ctx.events().emit(&Published { article_id }).await`. Clone it into the
197 /// route's state.
198 pub fn events(&self) -> &Events {
199 &self.events
200 }
201
202 /// The site's own origin, without a trailing slash: the configured
203 /// `app.url`, falling back to the bind address when none is declared.
204 ///
205 /// For the absolute URLs a route has to emit rather than link: a `<loc>` in a
206 /// sitemap, a canonical URL, an entry in a feed. The request's `Host` header
207 /// is not a substitute, because behind a proxy it is whatever the proxy sent.
208 pub fn base_url(&self) -> &str {
209 &self.base_url
210 }
211
212 /// The contributions of type `T` that the framework itself does not consume.
213 ///
214 /// This is how one module reads another's contributions. A module declaring
215 /// its own kind of extension point defines a type, other modules contribute
216 /// it from their `register`, and the route reads them here: the framework
217 /// never learns the type, and registration order does not matter because
218 /// everything is collected before any route runs.
219 pub fn contributions<T: Send + Sync + 'static>(&self) -> Vec<&T> {
220 self.plugin_defined.items::<T>()
221 }
222
223 /// Where this screen actually mounted, after the module namespace, any base
224 /// it declared, the deployment's overrides and the admin mount.
225 pub fn base_path(&self) -> &str {
226 &self.base_path
227 }
228
229 /// Where the admin panel is mounted, after `backend.path`. Ask rather than
230 /// assuming `/admin`: an operator may have moved it, and a module building a
231 /// link into the panel, or excluding it from a sitemap, needs the real one.
232 pub fn admin_path(&self) -> &str {
233 &self.admin_path
234 }
235
236 /// An absolute admin URL under this screen. Build every self-link this way:
237 /// a deployment can move the screen, so a hardcoded path would break.
238 pub fn url(&self, relative: &str) -> String {
239 join(&self.base_path, relative)
240 }
241
242 /// A context built by hand, for testing a route without booting the framework.
243 ///
244 /// `mount` takes a `RouteCtx`, and the framework's own constructor is private,
245 /// so without this a plugin could only exercise its routes through a full boot.
246 /// With it, a route is an ordinary `tower` service:
247 ///
248 /// ```ignore
249 /// let ctx = RouteCtx::builder(db).admin_path("/backoffice").build();
250 /// let response = MyRoute.mount(&ctx).oneshot(request).await?;
251 /// ```
252 ///
253 /// This is also how the context grows after 1.0: a new field gets a builder
254 /// method and a default, which `new` could not have offered.
255 pub fn builder(db: Db) -> RouteCtxBuilder {
256 RouteCtxBuilder {
257 db,
258 auth: None,
259 base_path: "/".to_string(),
260 admin_path: "/admin".to_string(),
261 base_url: "http://localhost".to_string(),
262 plugin_defined: None,
263 events: None,
264 }
265 }
266}
267
268/// Builds a [`RouteCtx`]. Every field has a default, so a test names only what it
269/// asserts on.
270pub struct RouteCtxBuilder {
271 db: Db,
272 auth: Option<AuthService>,
273 base_path: String,
274 admin_path: String,
275 base_url: String,
276 plugin_defined: Option<Arc<Registry>>,
277 events: Option<Events>,
278}
279
280impl RouteCtxBuilder {
281 /// Where the screen mounted. Defaults to `/`.
282 pub fn base_path(mut self, path: impl Into<String>) -> Self {
283 self.base_path = path.into();
284 self
285 }
286
287 /// Where the panel mounted. Defaults to `/admin`, which is what a route
288 /// excluding the panel should *not* be allowed to assume: set it to something
289 /// else in a test that checks the exclusion is read rather than hardcoded.
290 pub fn admin_path(mut self, path: impl Into<String>) -> Self {
291 self.admin_path = path.into();
292 self
293 }
294
295 /// The site's origin. Defaults to `http://localhost`.
296 pub fn base_url(mut self, url: impl Into<String>) -> Self {
297 self.base_url = url.into();
298 self
299 }
300
301 /// The contributions `RouteCtx::contributions` will read. Defaults to empty.
302 pub fn contributions(mut self, registry: Arc<Registry>) -> Self {
303 self.plugin_defined = Some(registry);
304 self
305 }
306
307 /// The bus `RouteCtx::events` will hand out. Defaults to one nothing
308 /// listens on; pass one built with a listener to assert on what a route
309 /// announces.
310 pub fn events(mut self, events: Events) -> Self {
311 self.events = Some(events);
312 self
313 }
314
315 /// The auth service [`RouteCtx::audit`] writes through. Without one, a
316 /// default service over the same database.
317 pub fn auth(mut self, auth: AuthService) -> Self {
318 self.auth = Some(auth);
319 self
320 }
321
322 pub fn build(self) -> RouteCtx {
323 let events = self.events.unwrap_or_else(|| Events::new(self.db.clone()));
324 let auth = self.auth.unwrap_or_else(|| {
325 AuthService::new(self.db.clone(), laterite_auth::AuthConfig::default())
326 });
327 RouteCtx::new(
328 self.db.clone(),
329 auth,
330 laterite_core::parameters::Parameters::new(self.db),
331 Arc::new(crate::upload_extensions(&[])),
332 Arc::new(crate::field::builtin_registry()),
333 Arc::new(crate::field::NoOverrides),
334 Arc::new(laterite_core::Crypt::ephemeral()),
335 "storage",
336 &self.base_path,
337 &self.admin_path,
338 &self.base_url,
339 self.plugin_defined
340 .unwrap_or_else(|| Arc::new(Registry::new())),
341 events,
342 Arc::new(HashMap::new()),
343 )
344 }
345}
346
347fn join(base: &str, relative: &str) -> String {
348 let base = base.trim_end_matches('/');
349 let relative = relative.trim_start_matches('/');
350 if relative.is_empty() {
351 base.to_string()
352 } else {
353 format!("{base}/{relative}")
354 }
355}
356
357/// A module's own admin screen.
358pub trait Screen: Send + Sync + 'static {
359 /// The routes this screen serves, relative to its own base. Handlers capture
360 /// what they need from `ctx`, so the router carries no framework state.
361 fn mount(&self, ctx: &RouteCtx) -> Router;
362}
363
364/// A registered screen: where it mounts, what it is called, and who may reach it.
365///
366/// Built with [`ScreenReg::new`] plus its builder methods. Non-exhaustive so the
367/// framework can learn something new about a screen after 1.0 without a major bump:
368/// anything it needs to know belongs here rather than as another trait method, which
369/// is also what keeps the code half replaceable at the plugin boundary.
370#[non_exhaustive]
371pub struct ScreenReg {
372 /// Path relative to the module's admin base, starting with a slash.
373 pub base_path: String,
374 /// Menu label, localized at render. `None` mounts it without a nav entry.
375 pub nav_label: Option<Text>,
376 /// Where the entry sits: under a section, with an icon, at a position.
377 pub menu: crate::nav::Placement,
378 /// The permission every route under this screen requires. Required, because
379 /// an unguarded screen exposes whatever it serves to any signed-in operator.
380 pub permission: String,
381 pub screen: Arc<dyn Screen>,
382}
383
384impl ScreenReg {
385 pub fn new(
386 base_path: impl Into<String>,
387 permission: impl Into<String>,
388 screen: Arc<dyn Screen>,
389 ) -> Self {
390 Self {
391 base_path: base_path.into(),
392 nav_label: None,
393 menu: crate::nav::Placement::default(),
394 permission: permission.into(),
395 screen,
396 }
397 }
398
399 /// Shows this screen in the menu under `label`.
400 pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
401 self.nav_label = Some(label.into());
402 self
403 }
404
405 /// Where the menu entry sits. See [`Placement`](crate::nav::Placement).
406 pub fn menu(mut self, placement: crate::nav::Placement) -> Self {
407 self.menu = placement;
408 self
409 }
410}
411
412/// A module's own route outside the admin.
413///
414/// Public by definition: no permission gate and no admin chrome, though the
415/// styled error pages still apply. A route that accepts a POST declares its own
416/// stance on CSRF, because the admin's blanket protection assumes a session.
417pub trait PublicRoute: Send + Sync + 'static {
418 /// The routes this endpoint serves, relative to its declared path.
419 fn mount(&self, ctx: &RouteCtx) -> Router;
420}
421
422/// A registered public route.
423///
424/// The path is literal and never namespaced: `/robots.txt` has to be exactly
425/// that. Collisions are therefore likelier than in the admin, so two claims on
426/// one path, or a claim on the admin mount, abort the boot.
427/// Built with [`PublicRouteReg::new`]. Non-exhaustive for the same reason as
428/// [`ScreenReg`].
429#[non_exhaustive]
430pub struct PublicRouteReg {
431 pub path: String,
432 pub route: Arc<dyn PublicRoute>,
433}
434
435impl PublicRouteReg {
436 pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
437 Self {
438 path: path.into(),
439 route,
440 }
441 }
442
443 /// The site itself: the route every URL reaches that no admin route and no
444 /// other public route serves, in place of the panel's own 404. One module
445 /// may register it; a second aborts the boot as a clash.
446 pub fn site(route: Arc<dyn PublicRoute>) -> Self {
447 Self::new("/", route)
448 }
449
450 /// Whether this registration is the site (see [`PublicRouteReg::site`]).
451 pub fn is_site(&self) -> bool {
452 self.path == "/"
453 }
454}
455
456/// A feature a module mounts under every resource whose descriptor declares
457/// its key: the routes behind `import: true`, say.
458///
459/// The admin owns the key and the button; the module owns the routes. A
460/// descriptor declaring a key no module serves aborts boot naming it, so the
461/// admin never depends on the module.
462pub trait ResourceFeature: Send + Sync + 'static {
463 /// The routes, relative to `{resource}/{key}`, which is `ctx.base_path()`.
464 fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router;
465}
466
467/// A registered feature, keyed by the descriptor key it serves. Mounted under
468/// every resource declaring that key, behind the key's permission or, when the
469/// key names none, the resource's.
470#[non_exhaustive]
471pub struct ResourceFeatureReg {
472 pub key: String,
473 pub feature: Arc<dyn ResourceFeature>,
474}
475
476impl ResourceFeatureReg {
477 pub fn new(key: impl Into<String>, feature: Arc<dyn ResourceFeature>) -> Self {
478 Self {
479 key: key.into(),
480 feature,
481 }
482 }
483}
484
485/// The resource a feature mounts under, as the feature sees it.
486#[derive(Clone)]
487#[non_exhaustive]
488pub struct ResourceInfo {
489 /// The resource's path as its descriptor declares it, relative to the
490 /// admin root (`/products`).
491 pub path: String,
492 /// Where its list mounted (`/admin/products`).
493 pub base_path: String,
494 /// The resource's menu label.
495 pub label: Text,
496 /// The permission the feature's routes are gated by.
497 pub permission: String,
498 /// The resource's form, to write its records through.
499 pub form: FormHandle,
500}
501
502impl ResourceInfo {
503 pub(crate) fn new(
504 path: String,
505 base_path: String,
506 label: Text,
507 permission: String,
508 form: FormHandle,
509 ) -> Self {
510 Self {
511 path,
512 base_path,
513 label,
514 permission,
515 form,
516 }
517 }
518}
519
520/// The link a list shows to a feature mounted under it.
521pub(crate) fn feature_link(base_path: &str, key: &str, permission: &str) -> ImportLink {
522 ImportLink {
523 href: format!("{base_path}/{key}"),
524 permission: permission.to_string(),
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn a_self_link_builds_from_where_the_screen_actually_mounted() {
534 let base = "/admin/rainmill/location/import";
535 assert_eq!(
536 join(base, "/step2"),
537 "/admin/rainmill/location/import/step2"
538 );
539 assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
540 assert_eq!(join(base, ""), "/admin/rainmill/location/import");
541 }
542
543 #[test]
544 fn a_moved_screen_moves_its_links_with_it() {
545 assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
546 }
547
548 #[test]
549 fn a_registration_defaults_to_no_menu_entry() {
550 struct Noop;
551 impl Screen for Noop {
552 fn mount(&self, _ctx: &RouteCtx) -> Router {
553 Router::new()
554 }
555 }
556 let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
557 assert!(reg.nav_label.is_none());
558 assert!(reg.in_menu("Import").nav_label.is_some());
559 }
560}
561
562/// Bytes from lowercase or uppercase hex; `None` on an odd length or a stray
563/// character.
564pub(crate) fn unhex(hex: &str) -> Option<Vec<u8>> {
565 if !hex.len().is_multiple_of(2) || !hex.is_ascii() {
566 return None;
567 }
568 (0..hex.len())
569 .step_by(2)
570 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
571 .collect()
572}
573
574#[cfg(test)]
575mod signing_tests {
576 use super::*;
577
578 #[tokio::test]
579 async fn a_route_signs_for_the_url_purpose_under_its_own_key() {
580 let (db, _guard) = laterite_core::testing::connect_test(&[]).await;
581 let ctx = RouteCtx::builder(db.clone()).build();
582 let sig = ctx.sign(b"media:abc:99");
583 assert!(ctx.verify_signature(b"media:abc:99", &sig));
584 assert!(!ctx.verify_signature(b"media:abc:98", &sig));
585 assert!(!ctx.crypt().verify("token", b"media:abc:99", &sig));
586 let other = RouteCtx::builder(db).build();
587 assert!(!other.verify_signature(b"media:abc:99", &sig));
588 }
589}