1use 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#[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 mail: laterite_core::Mail,
36 upload_extensions: Arc<Vec<String>>,
37 field_types: Arc<crate::field::FieldRegistry>,
38 overrides: Arc<dyn crate::field::OverrideResolver>,
39 crypt: Arc<laterite_core::Crypt>,
40 storage_dir: Arc<str>,
41 base_path: Arc<str>,
42 admin_path: Arc<str>,
43 base_url: Arc<str>,
44 plugin_defined: Arc<Registry>,
45 events: Events,
46 forms: Forms,
47}
48
49impl RouteCtx {
50 #[allow(clippy::too_many_arguments)]
51 pub(crate) fn new(
52 db: Db,
53 auth: AuthService,
54 parameters: laterite_core::parameters::Parameters,
55 queue: laterite_core::Queue,
56 schedule: laterite_core::Schedule,
57 mail: laterite_core::Mail,
58 upload_extensions: Arc<Vec<String>>,
59 field_types: Arc<crate::field::FieldRegistry>,
60 overrides: Arc<dyn crate::field::OverrideResolver>,
61 crypt: Arc<laterite_core::Crypt>,
62 storage_dir: &str,
63 base_path: &str,
64 admin_path: &str,
65 base_url: &str,
66 plugin_defined: Arc<Registry>,
67 events: Events,
68 forms: Forms,
69 ) -> Self {
70 Self {
71 db,
72 auth,
73 cache: parameters.cache().clone(),
74 parameters,
75 queue,
76 schedule,
77 mail,
78 upload_extensions,
79 field_types,
80 overrides,
81 crypt,
82 storage_dir: Arc::from(storage_dir.trim_end_matches('/')),
83 base_path: Arc::from(base_path),
84 admin_path: Arc::from(admin_path),
85 base_url: Arc::from(base_url),
86 plugin_defined,
87 events,
88 forms,
89 }
90 }
91
92 pub async fn audit(
96 &self,
97 user: &AuthenticatedUser,
98 action: &str,
99 target_type: Option<&str>,
100 target_id: Option<&str>,
101 target_label: Option<&str>,
102 detail: Option<&str>,
103 ) {
104 let actor = laterite_core::Actor::user(user.user.id, user.user.username.clone());
105 let mut entry = AuditEntry::new(&actor, action);
106 if let (Some(kind), Some(id)) = (target_type, target_id) {
107 entry = entry.target(kind, id, target_label);
108 }
109 if let Some(detail) = detail {
110 entry = entry.detail(detail);
111 }
112 if let Err(e) = self.auth.record_audit(entry).await {
113 tracing::error!(action, error = %e, "failed to write audit log entry");
114 }
115 }
116
117 pub fn value_form(&self, fields: &[FormField], scope: &str) -> Result<ValueForm, Text> {
122 ValueForm::new(
123 fields,
124 self.field_types.clone(),
125 self.overrides.clone(),
126 scope,
127 )
128 }
129
130 pub async fn operator(&self, headers: &axum::http::HeaderMap) -> Option<AuthenticatedUser> {
133 let token = headers
134 .get_all(axum::http::header::COOKIE)
135 .iter()
136 .filter_map(|v| v.to_str().ok())
137 .flat_map(|line| line.split(';'))
138 .filter_map(|pair| pair.trim().split_once('='))
139 .find(|(name, _)| *name == crate::SESSION_COOKIE)
140 .map(|(_, value)| value.trim().to_string())?;
141 self.auth.verify_session(&token).await.ok()
142 }
143
144 pub async fn record_unserved(&self, method: &str, path: &str, status: u16) {
147 crate::requestlog::record(&self.db, method, path, status).await;
148 }
149
150 pub fn upload_extensions(&self) -> &[String] {
155 &self.upload_extensions
156 }
157
158 pub fn storage_dir(&self) -> &str {
161 &self.storage_dir
162 }
163
164 pub fn sign(&self, message: &[u8]) -> String {
169 self.crypt.sign("url", message)
170 }
171
172 pub fn verify_signature(&self, message: &[u8], signature: &str) -> bool {
175 self.crypt.verify("url", message, signature)
176 }
177
178 pub fn crypt(&self) -> &laterite_core::Crypt {
180 &self.crypt
181 }
182
183 pub fn db(&self) -> &Db {
184 &self.db
185 }
186
187 pub fn parameters(&self) -> &laterite_core::parameters::Parameters {
190 &self.parameters
191 }
192
193 pub fn cache(&self) -> &laterite_core::Cache {
195 &self.cache
196 }
197
198 pub fn queue(&self) -> &laterite_core::Queue {
200 &self.queue
201 }
202
203 pub fn schedule(&self) -> &laterite_core::Schedule {
205 &self.schedule
206 }
207
208 pub fn mail(&self) -> &laterite_core::Mail {
210 &self.mail
211 }
212
213 pub fn settings(&self) -> crate::settings::Settings {
216 crate::settings::Settings::new(self.db.clone(), self.cache.clone())
217 }
218
219 pub fn form(&self, path: &str) -> Option<FormHandle> {
227 self.forms
228 .get(path)
229 .map(|form| FormHandle::new(self.db.clone(), self.cache.clone(), form.clone()))
230 }
231
232 pub fn events(&self) -> &Events {
236 &self.events
237 }
238
239 pub fn base_url(&self) -> &str {
246 &self.base_url
247 }
248
249 pub fn contributions<T: Send + Sync + 'static>(&self) -> Vec<&T> {
257 self.plugin_defined.items::<T>()
258 }
259
260 pub fn base_path(&self) -> &str {
263 &self.base_path
264 }
265
266 pub fn admin_path(&self) -> &str {
270 &self.admin_path
271 }
272
273 pub fn url(&self, relative: &str) -> String {
276 join(&self.base_path, relative)
277 }
278
279 pub fn builder(db: Db) -> RouteCtxBuilder {
293 RouteCtxBuilder {
294 db,
295 auth: None,
296 base_path: "/".to_string(),
297 admin_path: "/admin".to_string(),
298 base_url: "http://localhost".to_string(),
299 plugin_defined: None,
300 events: None,
301 cache: None,
302 queue: None,
303 schedule: None,
304 mail: None,
305 }
306 }
307}
308
309pub struct RouteCtxBuilder {
312 db: Db,
313 auth: Option<AuthService>,
314 base_path: String,
315 admin_path: String,
316 base_url: String,
317 plugin_defined: Option<Arc<Registry>>,
318 events: Option<Events>,
319 cache: Option<laterite_core::Cache>,
320 queue: Option<laterite_core::Queue>,
321 schedule: Option<laterite_core::Schedule>,
322 mail: Option<laterite_core::Mail>,
323}
324
325impl RouteCtxBuilder {
326 pub fn mail(mut self, mail: laterite_core::Mail) -> Self {
329 self.mail = Some(mail);
330 self
331 }
332
333 pub fn schedule(mut self, schedule: laterite_core::Schedule) -> Self {
336 self.schedule = Some(schedule);
337 self
338 }
339
340 pub fn queue(mut self, queue: laterite_core::Queue) -> Self {
343 self.queue = Some(queue);
344 self
345 }
346
347 pub fn cache(mut self, cache: laterite_core::Cache) -> Self {
349 self.cache = Some(cache);
350 self
351 }
352
353 pub fn base_path(mut self, path: impl Into<String>) -> Self {
355 self.base_path = path.into();
356 self
357 }
358
359 pub fn admin_path(mut self, path: impl Into<String>) -> Self {
363 self.admin_path = path.into();
364 self
365 }
366
367 pub fn base_url(mut self, url: impl Into<String>) -> Self {
369 self.base_url = url.into();
370 self
371 }
372
373 pub fn contributions(mut self, registry: Arc<Registry>) -> Self {
375 self.plugin_defined = Some(registry);
376 self
377 }
378
379 pub fn events(mut self, events: Events) -> Self {
383 self.events = Some(events);
384 self
385 }
386
387 pub fn auth(mut self, auth: AuthService) -> Self {
390 self.auth = Some(auth);
391 self
392 }
393
394 pub fn build(self) -> RouteCtx {
395 let events = self.events.unwrap_or_else(|| Events::new(self.db.clone()));
396 let auth = self.auth.unwrap_or_else(|| {
397 AuthService::new(self.db.clone(), laterite_auth::AuthConfig::default())
398 });
399 let cache = self.cache.unwrap_or_else(laterite_core::Cache::local);
400 let parameters =
401 laterite_core::parameters::Parameters::with_cache(self.db.clone(), cache.clone());
402 let queue = self.queue.unwrap_or_else(|| {
403 laterite_core::Queue::inline(
404 Vec::new(),
405 laterite_core::queue::Services {
406 db: self.db.clone(),
407 events: events.clone(),
408 cache: cache.clone(),
409 parameters: parameters.clone(),
410 },
411 )
412 });
413 let schedule = self.schedule.unwrap_or_else(|| {
414 laterite_core::Schedule::new(
415 Vec::new(),
416 laterite_core::schedule::Services {
417 db: self.db.clone(),
418 events: events.clone(),
419 cache: cache.clone(),
420 parameters: parameters.clone(),
421 queue: queue.clone(),
422 },
423 )
424 .expect("no entry, nothing to refuse")
425 });
426 let mail = self.mail.unwrap_or_else(|| {
427 laterite_core::Mail::new(
428 Arc::new(laterite_core::mail::StubMailer::new()),
429 laterite_core::mail::Sender::from_config(
430 &laterite_core::config::MailConfig::default(),
431 )
432 .expect("the default sender parses"),
433 laterite_core::mail::Templates::new(self.db.clone(), cache.clone()),
434 queue.clone(),
435 laterite_core::mail::drivers([]),
436 self.db.clone(),
437 )
438 });
439 RouteCtx::new(
440 self.db.clone(),
441 auth,
442 parameters,
443 queue,
444 schedule,
445 mail,
446 Arc::new(crate::upload_extensions(&[])),
447 Arc::new(crate::field::builtin_registry()),
448 Arc::new(crate::field::NoOverrides),
449 Arc::new(laterite_core::Crypt::ephemeral()),
450 "storage",
451 &self.base_path,
452 &self.admin_path,
453 &self.base_url,
454 self.plugin_defined
455 .unwrap_or_else(|| Arc::new(Registry::new())),
456 events,
457 Arc::new(HashMap::new()),
458 )
459 }
460}
461
462fn join(base: &str, relative: &str) -> String {
463 let base = base.trim_end_matches('/');
464 let relative = relative.trim_start_matches('/');
465 if relative.is_empty() {
466 base.to_string()
467 } else {
468 format!("{base}/{relative}")
469 }
470}
471
472pub trait Screen: Send + Sync + 'static {
474 fn mount(&self, ctx: &RouteCtx) -> Router;
477}
478
479#[non_exhaustive]
486pub struct ScreenReg {
487 pub base_path: String,
489 pub nav_label: Option<Text>,
491 pub menu: crate::nav::Placement,
493 pub permission: String,
496 pub screen: Arc<dyn Screen>,
497}
498
499impl ScreenReg {
500 pub fn new(
501 base_path: impl Into<String>,
502 permission: impl Into<String>,
503 screen: Arc<dyn Screen>,
504 ) -> Self {
505 Self {
506 base_path: base_path.into(),
507 nav_label: None,
508 menu: crate::nav::Placement::default(),
509 permission: permission.into(),
510 screen,
511 }
512 }
513
514 pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
516 self.nav_label = Some(label.into());
517 self
518 }
519
520 pub fn menu(mut self, placement: crate::nav::Placement) -> Self {
522 self.menu = placement;
523 self
524 }
525}
526
527pub trait PublicRoute: Send + Sync + 'static {
533 fn mount(&self, ctx: &RouteCtx) -> Router;
535}
536
537#[non_exhaustive]
545pub struct PublicRouteReg {
546 pub path: String,
547 pub route: Arc<dyn PublicRoute>,
548}
549
550impl PublicRouteReg {
551 pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
552 Self {
553 path: path.into(),
554 route,
555 }
556 }
557
558 pub fn site(route: Arc<dyn PublicRoute>) -> Self {
562 Self::new("/", route)
563 }
564
565 pub fn is_site(&self) -> bool {
567 self.path == "/"
568 }
569}
570
571pub trait ResourceFeature: Send + Sync + 'static {
578 fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router;
580}
581
582#[non_exhaustive]
586pub struct ResourceFeatureReg {
587 pub key: String,
588 pub feature: Arc<dyn ResourceFeature>,
589}
590
591impl ResourceFeatureReg {
592 pub fn new(key: impl Into<String>, feature: Arc<dyn ResourceFeature>) -> Self {
593 Self {
594 key: key.into(),
595 feature,
596 }
597 }
598}
599
600#[derive(Clone)]
602#[non_exhaustive]
603pub struct ResourceInfo {
604 pub path: String,
607 pub base_path: String,
609 pub label: Text,
611 pub permission: String,
613 pub form: FormHandle,
615}
616
617impl ResourceInfo {
618 pub(crate) fn new(
619 path: String,
620 base_path: String,
621 label: Text,
622 permission: String,
623 form: FormHandle,
624 ) -> Self {
625 Self {
626 path,
627 base_path,
628 label,
629 permission,
630 form,
631 }
632 }
633}
634
635pub(crate) fn feature_link(base_path: &str, key: &str, permission: &str) -> ImportLink {
637 ImportLink {
638 href: format!("{base_path}/{key}"),
639 permission: permission.to_string(),
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn a_self_link_builds_from_where_the_screen_actually_mounted() {
649 let base = "/admin/rainmill/location/import";
650 assert_eq!(
651 join(base, "/step2"),
652 "/admin/rainmill/location/import/step2"
653 );
654 assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
655 assert_eq!(join(base, ""), "/admin/rainmill/location/import");
656 }
657
658 #[test]
659 fn a_moved_screen_moves_its_links_with_it() {
660 assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
661 }
662
663 #[test]
664 fn a_registration_defaults_to_no_menu_entry() {
665 struct Noop;
666 impl Screen for Noop {
667 fn mount(&self, _ctx: &RouteCtx) -> Router {
668 Router::new()
669 }
670 }
671 let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
672 assert!(reg.nav_label.is_none());
673 assert!(reg.in_menu("Import").nav_label.is_some());
674 }
675}
676
677pub(crate) fn unhex(hex: &str) -> Option<Vec<u8>> {
680 if !hex.len().is_multiple_of(2) || !hex.is_ascii() {
681 return None;
682 }
683 (0..hex.len())
684 .step_by(2)
685 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
686 .collect()
687}
688
689#[cfg(test)]
690mod signing_tests {
691 use super::*;
692
693 #[tokio::test]
694 async fn a_route_signs_for_the_url_purpose_under_its_own_key() {
695 let (db, _guard) = laterite_core::testing::connect_test(&[]).await;
696 let ctx = RouteCtx::builder(db.clone()).build();
697 let sig = ctx.sign(b"media:abc:99");
698 assert!(ctx.verify_signature(b"media:abc:99", &sig));
699 assert!(!ctx.verify_signature(b"media:abc:98", &sig));
700 assert!(!ctx.crypt().verify("token", b"media:abc:99", &sig));
701 let other = RouteCtx::builder(db).build();
702 assert!(!other.verify_signature(b"media:abc:99", &sig));
703 }
704}