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}