1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23pub mod editor;
24mod error;
25mod export;
26pub mod field;
27pub mod form;
28pub mod html;
29pub mod http_cache;
30mod icons;
31pub mod import;
32mod jobs;
33pub(crate) mod keyed;
34pub mod list;
35mod maillog;
36mod mailtemplates;
37pub mod medialib;
38pub mod nav;
39pub(crate) mod palette;
40mod passwordlinks;
41pub mod persist;
42pub mod picker;
43pub mod plugins;
44pub mod relation;
45pub(crate) mod requestlog;
46mod richtext;
47mod roles;
48mod routemap;
49pub mod routes;
50mod scheduled;
51mod session;
52pub mod settings;
53pub mod skin;
54mod sql;
55pub mod ui;
56mod upload;
57mod users;
58pub mod widget;
59mod widgets;
60
61pub use axum;
69
70pub use bootstrap::{
71 crypt_from_config, legacy_key_material, AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX,
72};
73pub use error::AdminError;
74pub use session::{FlashLevel, SessionHandle};
75pub use upload::VerifiedUpload;
76
77use std::collections::HashMap;
78use std::sync::Arc;
79
80use askama::Template;
81use axum::body::Body;
82use axum::extract::{Path, Query, Request, State};
83use axum::http::{header, HeaderMap};
84use axum::middleware::{self, Next};
85use axum::response::{Html, IntoResponse, Redirect, Response};
86use axum::routing::{any, get, post};
87use axum::{Extension, Form, Router};
88use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
89use chrono_tz::{Tz, TZ_VARIANTS};
90use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
91use laterite_core::{t, CatalogStore, Db, Text, Translator};
92use serde::{Deserialize, Serialize};
93
94pub trait AdminRegistry {
100 fn add_resource(&mut self, resource: Resource);
102 fn add_permission(&mut self, permission: Permission);
104 fn add_settings(&mut self, item: settings::SettingsItem);
106 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
108 fn add_persister(&mut self, persister: persist::PersisterReg);
110 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
112 fn add_screen(&mut self, screen: routes::ScreenReg);
115 fn add_public_route(&mut self, route: routes::PublicRouteReg);
118 fn add_nav(&mut self, item: nav::NavItem);
120 fn add_resource_hook(&mut self, hook: ResourceHook);
122 fn add_editor_command(&mut self, command: editor::EditorCommand);
124 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>);
126 fn add_widget(&mut self, widget: widget::WidgetReg);
128 fn add_skin(&mut self, skin: skin::Skin);
130 fn add_asset(&mut self, key: &'static str, asset: AdminAsset);
134}
135
136impl AdminRegistry for laterite_core::Registry {
137 fn add_resource(&mut self, resource: Resource) {
138 self.add(resource);
139 }
140 fn add_permission(&mut self, permission: Permission) {
141 self.add(permission);
142 }
143 fn add_settings(&mut self, item: settings::SettingsItem) {
144 self.add(item);
145 }
146 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
147 self.add(source);
148 }
149 fn add_screen(&mut self, screen: routes::ScreenReg) {
150 self.add(screen);
151 }
152 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
153 self.add(route);
154 }
155 fn add_nav(&mut self, item: nav::NavItem) {
156 self.add(item);
157 }
158 fn add_resource_hook(&mut self, hook: ResourceHook) {
159 self.add(hook);
160 }
161 fn add_editor_command(&mut self, command: editor::EditorCommand) {
162 self.add(command);
163 }
164 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>) {
165 self.add(editor::SnippetSourceReg(source));
166 }
167 fn add_widget(&mut self, widget: widget::WidgetReg) {
168 self.add(widget);
169 }
170 fn add_skin(&mut self, skin: skin::Skin) {
171 self.add(skin);
172 }
173 fn add_asset(&mut self, key: &'static str, asset: AdminAsset) {
174 self.add(AssetReg { key, asset });
175 }
176 fn add_persister(&mut self, persister: persist::PersisterReg) {
177 self.add(persister);
178 }
179 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
180 self.add(field_type);
181 }
182}
183
184#[derive(Clone)]
191pub struct ResourceHook {
192 pub target: String,
193 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
194}
195
196impl ResourceHook {
197 pub fn new(
198 target: impl Into<String>,
199 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
200 ) -> Self {
201 Self {
202 target: target.into(),
203 apply: Arc::new(apply),
204 }
205 }
206
207 pub fn apply(&self, resource: Resource) -> Resource {
209 (self.apply)(resource)
210 }
211}
212
213impl std::fmt::Debug for ResourceHook {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 write!(f, "ResourceHook({})", self.target)
216 }
217}
218
219pub(crate) const SESSION_COOKIE: &str = "laterite_session";
220const REMEMBER_COOKIE: &str = "laterite_remember";
224
225pub(crate) const ICON_SPRITE: &str = "icons.svg";
228
229#[derive(Clone)]
231pub(crate) struct AdminState {
232 auth: AuthService,
233 db: Db,
234 parameters: laterite_core::parameters::Parameters,
236 upload_extensions: Arc<Vec<String>>,
238 nav: Arc<nav::NavTree>,
239 nav_items: Arc<Vec<nav::NavItem>>,
242 settings: Arc<Vec<settings::SettingsItem>>,
243 permissions: Arc<Vec<Permission>>,
244 admin_path: Arc<str>,
248 cookie_path: Arc<str>,
252 storage_dir: Arc<str>,
254 crypt: Arc<laterite_core::Crypt>,
256 order: std::collections::BTreeMap<String, Vec<String>>,
258 secure_cookie: bool,
259 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
261 icons: Arc<laterite_core::icons::IconSet>,
263 sprite_url: Arc<str>,
265 origin: Arc<str>,
267 timezone: Tz,
268 default_locale: Arc<str>,
272 catalogs: Arc<CatalogStore>,
275 app_name: String,
278 cache: laterite_core::Cache,
281 queue: laterite_core::Queue,
283 schedule: laterite_core::Schedule,
285 mail: laterite_core::Mail,
287 mail_config: Arc<laterite_core::config::MailConfig>,
289 skin: Arc<skin::Active>,
291 asset_urls: Arc<AssetUrls>,
293 field_types: Arc<field::FieldRegistry>,
296 column_types: Arc<list::ColumnRegistry>,
299 widgets: Arc<Vec<widget::WidgetReg>>,
302 plugin_defined: Arc<laterite_core::Registry>,
305 assets: Arc<AssetRegistry>,
307 pickers: Arc<picker::PickerRegistry>,
310 palette: Arc<Vec<palette::RecordSource>>,
313 overrides: Arc<dyn field::OverrideResolver>,
317}
318
319impl AdminState {
320 #[cfg(test)]
321 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
322 Self {
323 auth,
324 parameters: laterite_core::parameters::Parameters::new(db.clone()),
325 cache: laterite_core::Cache::local(),
326 queue: inline_queue(&db, laterite_core::Cache::local()),
327 schedule: empty_schedule(
328 &db,
329 laterite_core::Cache::local(),
330 inline_queue(&db, laterite_core::Cache::local()),
331 ),
332 mail: stub_mail(
333 &db,
334 laterite_core::Cache::local(),
335 inline_queue(&db, laterite_core::Cache::local()),
336 ),
337 mail_config: Arc::new(laterite_core::config::MailConfig::default()),
338 upload_extensions: Arc::new(upload_extensions(&[])),
339 db,
340 nav: Arc::new(nav::NavTree::default()),
341 nav_items: Arc::new(Vec::new()),
342 settings: Arc::new(Vec::new()),
343 permissions: Arc::new(builtin_permissions()),
344 trusted_proxies: Arc::new(Vec::new()),
345 icons: Arc::new(laterite_core::icons::IconSet::new()),
346 sprite_url: Arc::from(""),
347 admin_path: Arc::from("/admin"),
348 cookie_path: Arc::from("/admin"),
349 storage_dir: Arc::from("storage"),
350 crypt: Arc::new(laterite_core::Crypt::ephemeral()),
351 order: std::collections::BTreeMap::new(),
352 secure_cookie: false,
353 origin: Arc::from(""),
354 timezone: Tz::UTC,
355 default_locale: Arc::from("en"),
356 catalogs: Arc::new(CatalogStore::default()),
357 app_name: "Laterite".to_string(),
358 skin: Arc::new(skin::Active::default()),
359 field_types: Arc::new(field::builtin_registry()),
360 column_types: Arc::new(list::builtin_column_registry()),
361 widgets: Arc::new(widgets::builtin()),
362 plugin_defined: Arc::new(laterite_core::Registry::new()),
363 assets: Arc::new(builtin_assets()),
364 asset_urls: Arc::new(asset_urls(&builtin_assets())),
365 pickers: Arc::new(picker::PickerRegistry::new()),
366 palette: Arc::new(Vec::new()),
367 overrides: Arc::new(field::NoOverrides),
368 }
369 }
370
371 async fn brand(&self) -> Arc<settings::brand::Brand> {
375 let resolved = self
376 .cache
377 .remember(
378 BRAND_CACHE_KEY,
379 BRAND_TTL,
380 &[settings::NAMESPACE],
381 || async { Ok(settings::brand::Brand::resolve(&self.db, &self.app_name).await) },
382 )
383 .await;
384 Arc::new(match resolved {
385 Ok(brand) => brand,
386 Err(e) => {
387 tracing::warn!(error = %e, "brand not cached");
388 settings::brand::Brand::resolve(&self.db, &self.app_name).await
389 }
390 })
391 }
392
393 pub(crate) fn settings_store(&self) -> settings::Settings {
395 settings::Settings::new(self.db.clone(), self.cache.clone())
396 }
397}
398
399const BRAND_CACHE_KEY: &str = "laterite.backend::brand";
401const BRAND_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
403
404#[derive(Clone)]
410#[non_exhaustive]
411pub struct AdminConfig {
412 pub secure_cookie: bool,
415 pub trusted_proxies: Vec<String>,
419 pub timezone: String,
423 pub locale: String,
427 pub app_name: String,
431 pub path: String,
435 pub origin: String,
440 pub storage_dir: String,
445 pub skin: String,
448 pub upload_extensions: Vec<String>,
451 pub order: std::collections::BTreeMap<String, Vec<String>>,
454 pub menu: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
456 pub secret: Vec<u8>,
461 pub crypt: Option<Arc<laterite_core::Crypt>>,
464 pub cache: Option<laterite_core::Cache>,
467 pub queue: Option<laterite_core::Queue>,
471 pub schedule: Option<laterite_core::Schedule>,
474 pub mail: Option<laterite_core::Mail>,
478 pub mail_config: laterite_core::config::MailConfig,
480}
481
482impl Default for AdminConfig {
483 fn default() -> Self {
484 Self {
485 secure_cookie: false,
486 trusted_proxies: Vec::new(),
487 timezone: "UTC".to_string(),
488 locale: "en".to_string(),
489 app_name: "Laterite".to_string(),
490 path: "/admin".to_string(),
491 origin: String::new(),
492 storage_dir: "storage".to_string(),
493 skin: "standard".to_string(),
494 upload_extensions: Vec::new(),
495 order: std::collections::BTreeMap::new(),
496 menu: std::collections::BTreeMap::new(),
497 secret: random_key(),
498 crypt: None,
499 cache: None,
500 queue: None,
501 schedule: None,
502 mail: None,
503 mail_config: laterite_core::config::MailConfig::default(),
504 }
505 }
506}
507
508fn inline_queue(db: &Db, cache: laterite_core::Cache) -> laterite_core::Queue {
511 laterite_core::Queue::inline(
512 Vec::new(),
513 laterite_core::queue::Services {
514 db: db.clone(),
515 events: laterite_core::Events::new(db.clone()),
516 parameters: laterite_core::parameters::Parameters::with_cache(
517 db.clone(),
518 cache.clone(),
519 ),
520 cache,
521 },
522 )
523}
524
525fn empty_schedule(
527 db: &Db,
528 cache: laterite_core::Cache,
529 queue: laterite_core::Queue,
530) -> laterite_core::Schedule {
531 laterite_core::Schedule::new(
532 Vec::new(),
533 laterite_core::schedule::Services {
534 db: db.clone(),
535 events: laterite_core::Events::new(db.clone()),
536 parameters: laterite_core::parameters::Parameters::with_cache(
537 db.clone(),
538 cache.clone(),
539 ),
540 cache,
541 queue,
542 },
543 )
544 .expect("no entry, nothing to refuse")
545}
546
547fn stub_mail(
550 db: &Db,
551 cache: laterite_core::Cache,
552 queue: laterite_core::Queue,
553) -> laterite_core::Mail {
554 laterite_core::Mail::new(
555 Arc::new(laterite_core::mail::StubMailer::new()),
556 laterite_core::mail::Sender::from_config(&laterite_core::config::MailConfig::default())
557 .expect("the default sender parses"),
558 laterite_core::mail::Templates::new(db.clone(), cache),
559 queue,
560 laterite_core::mail::drivers([]),
561 db.clone(),
562 )
563}
564
565pub(crate) fn random_key() -> Vec<u8> {
567 use rand::RngCore;
568 let mut key = vec![0u8; 32];
569 rand::thread_rng().fill_bytes(&mut key);
570 key
571}
572
573#[derive(Clone)]
582pub struct Shell {
583 pub(crate) base: String,
587 brand: String,
591 brand_look: Arc<settings::brand::Brand>,
593 skin: Arc<skin::Active>,
594 nav: Vec<NavView>,
595 full_name: String,
596 initial: String,
597 tz: Tz,
601 i18n: Translator,
605 sidebar: Vec<settings::CategoryView>,
609 crumbs: Vec<CrumbView>,
612 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
614 pub(crate) sprite_url: Arc<str>,
617 site_url: String,
621 asset_urls: Arc<AssetUrls>,
625 pub(crate) csrf_token: String,
629 pub(crate) flash: Vec<FlashLine>,
633 pub(crate) assets: Vec<PageAsset>,
637}
638
639#[derive(Template)]
641#[template(path = "page.html")]
642struct PageTemplate {
643 title: String,
644 back: Option<(String, String)>,
646 actions: Option<html::Markup>,
648 aside: Option<(String, html::Markup)>,
650 body: html::Markup,
651 shell: Shell,
652}
653
654pub struct PageHead {
665 title: Text,
666 back: Option<(Text, String)>,
667 actions: Option<html::Markup>,
668 aside: Option<(Text, html::Markup)>,
669 assets: Vec<&'static str>,
670}
671
672impl PageHead {
673 pub fn new(title: impl Into<Text>) -> Self {
674 Self {
675 title: title.into(),
676 back: None,
677 actions: None,
678 aside: None,
679 assets: Vec::new(),
680 }
681 }
682
683 pub fn assets(mut self, keys: &[&'static str]) -> Self {
687 for key in keys {
688 if !self.assets.contains(key) {
689 self.assets.push(key);
690 }
691 }
692 self
693 }
694
695 pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
697 self.back = Some((label.into(), href.into()));
698 self
699 }
700
701 pub fn actions(mut self, actions: html::Markup) -> Self {
704 self.actions = Some(actions);
705 self
706 }
707
708 pub fn aside(mut self, label: impl Into<Text>, panel: html::Markup) -> Self {
712 self.aside = Some((label.into(), panel));
713 self
714 }
715}
716
717#[derive(Clone)]
719pub(crate) struct PageAsset {
720 pub(crate) url: String,
721 pub(crate) css: bool,
722}
723
724#[derive(Clone)]
727pub(crate) struct FlashLine {
728 pub(crate) level: session::FlashLevel,
729 pub(crate) text: String,
730 pub(crate) sticky: bool,
732}
733
734impl Shell {
735 #[allow(clippy::too_many_arguments)]
736 fn new(
737 base: &str,
738 brand: Arc<settings::brand::Brand>,
739 skin: Arc<skin::Active>,
740 nav: &[nav::NavNode],
741 user: &AuthenticatedUser,
742 default_tz: Tz,
743 sidebar: Vec<settings::CategoryView>,
744 active_nav: Option<&str>,
745 csrf_token: String,
746 flash: Vec<session::Flash>,
747 i18n: Translator,
748 asset_urls: Arc<AssetUrls>,
749 site_url: String,
750 icon_set: Arc<laterite_core::icons::IconSet>,
751 sprite_url: Arc<str>,
752 ) -> Self {
753 let full_name = user.user.full_name();
754 let initial = full_name
755 .chars()
756 .next()
757 .map(|c| c.to_uppercase().to_string())
758 .unwrap_or_else(|| "?".to_string());
759 let flash = flash
762 .into_iter()
763 .map(|f| FlashLine {
764 sticky: f.sticky,
765 level: f.level,
766 text: i18n.t(&f.text),
767 })
768 .collect();
769 let nav: Vec<NavView> = nav
770 .iter()
771 .map(|n| NavView {
772 key: n.item.key.clone(),
773 label: i18n.t(&n.item.label),
774 path: nav::tab_path(n).unwrap_or_default().to_string(),
775 active: active_nav == Some(n.item.key.as_str()),
776 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
777 })
778 .collect();
779 let mut crumbs: Vec<CrumbView> = nav
780 .iter()
781 .filter(|n: &&NavView| n.active)
782 .map(|n| CrumbView {
783 label: n.label.clone(),
784 href: n.path.clone(),
785 })
786 .collect();
787 if let Some(entry) = sidebar
788 .iter()
789 .flat_map(|g| g.items.iter())
790 .find(|i| i.active)
791 {
792 crumbs.push(CrumbView {
793 label: entry.label.clone(),
794 href: entry.path.clone(),
795 });
796 }
797 Shell {
798 asset_urls,
799 icon_set,
800 sprite_url,
801 site_url,
802 base: base.to_string(),
803 brand: brand.name.clone(),
804 brand_look: brand,
805 skin,
806 nav,
807 crumbs,
808 full_name,
809 initial,
810 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
811 i18n,
812 sidebar,
813 csrf_token,
814 flash,
815 assets: Vec::new(),
816 }
817 }
818
819 pub(crate) fn site_url(&self) -> &str {
821 &self.site_url
822 }
823
824 pub(crate) fn sidebar_anchored(&self) -> bool {
827 self.nav.iter().any(|n| n.active)
828 }
829
830 pub fn icon(&self, name: &str) -> String {
834 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
835 }
836
837 pub(crate) fn asset(&self, key: &str) -> String {
842 match self.asset_urls.get(key) {
843 Some(path) => format!("{}/assets/{path}", self.base),
844 None => {
845 debug_assert!(false, "asset key `{key}` is not registered");
846 format!("{}/assets/{key}", self.base)
847 }
848 }
849 }
850
851 pub fn t(&self, source: &str) -> String {
854 self.i18n.t(&Text::dynamic(source))
855 }
856
857 pub fn tt(&self, text: &Text) -> String {
860 self.i18n.t(text)
861 }
862
863 pub fn i18n(&self) -> &Translator {
866 &self.i18n
867 }
868
869 pub fn locale(&self) -> &str {
871 self.i18n.locale()
872 }
873
874 pub fn base(&self) -> &str {
876 &self.base
877 }
878
879 pub fn csrf_token(&self) -> &str {
883 &self.csrf_token
884 }
885
886 pub fn brand_logo(&self) -> Option<&str> {
888 self.brand_look.logo_url.as_deref()
889 }
890 pub fn brand_favicon(&self) -> Option<&str> {
892 self.brand_look.favicon_url.as_deref()
893 }
894 pub fn brand_css(&self) -> &str {
896 &self.brand_look.css
897 }
898 pub fn skin_code(&self) -> &str {
901 &self.skin.code
902 }
903
904 pub fn skin_css(&self) -> Option<&str> {
906 self.skin.stylesheet.as_deref()
907 }
908
909 pub fn mode_default(&self) -> &str {
910 &self.brand_look.mode
911 }
912
913 pub fn datetime(&self, utc: &str) -> String {
916 match chrono::DateTime::parse_from_rfc3339(utc) {
917 Ok(at) => at
918 .with_timezone(&self.tz)
919 .format("%Y-%m-%d %H:%M")
920 .to_string(),
921 Err(_) => utc.to_string(),
922 }
923 }
924
925 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
929 self.page_with(PageHead::new(title.clone()), body)
930 }
931
932 pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
935 let mut shell = self.clone();
936 for key in &head.assets {
937 let url = shell.asset(key);
938 if !shell.assets.iter().any(|a| a.url == url) {
939 shell.assets.push(PageAsset {
940 url,
941 css: key.ends_with(".css"),
942 });
943 }
944 }
945 render(PageTemplate {
946 title: self.tt(&head.title),
947 back: head.back.map(|(label, href)| (self.tt(&label), href)),
948 actions: head.actions,
949 aside: head.aside.map(|(label, panel)| (self.tt(&label), panel)),
950 body,
951 shell,
952 })
953 }
954
955 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
959 let mut text = Text::dynamic(source);
960 for (name, value) in args {
961 text = text.arg(*name, *value);
962 }
963 self.i18n.t(&text)
964 }
965
966 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
969 let mut text = Text::dynamic(source);
970 for (name, value) in args {
971 text = text.arg(*name, *value);
972 }
973 self.i18n.t(&text)
974 }
975
976 #[cfg(test)]
977 pub(crate) fn test() -> Self {
978 Shell {
979 asset_urls: Arc::new(asset_urls(&builtin_assets())),
980 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
981 sprite_url: Arc::from("/admin/assets/icons.svg"),
982 site_url: "http://localhost".to_string(),
983 base: "/admin".to_string(),
984 brand: "Laterite".to_string(),
985 brand_look: Arc::new(settings::brand::Brand::of_name("Laterite")),
986 skin: Arc::new(skin::Active::default()),
987 nav: Vec::new(),
988 full_name: "Test Operator".to_string(),
989 initial: "T".to_string(),
990 tz: Tz::UTC,
991 i18n: Translator::new("en"),
992 sidebar: Vec::new(),
993 crumbs: Vec::new(),
994 csrf_token: "test-csrf-token".to_string(),
995 flash: Vec::new(),
996 assets: Vec::new(),
997 }
998 }
999}
1000
1001fn settings_context(
1008 visible: &[settings::SettingsItem],
1009 admin_path: &str,
1010 path: &str,
1011) -> (bool, Option<String>) {
1012 if path == format!("{admin_path}/settings") {
1013 return (true, None);
1014 }
1015 let own = visible
1021 .iter()
1022 .filter(|i| i.link.is_none())
1023 .find(|i| path == format!("{admin_path}{}", i.route()))
1024 .map(|i| i.code.clone());
1025 if own.is_some() {
1026 return (true, own);
1027 }
1028 let linked = visible
1029 .iter()
1030 .filter_map(|i| {
1031 i.link
1032 .as_deref()
1033 .map(|link| (format!("{admin_path}{link}"), &i.code))
1034 })
1035 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
1036 .max_by_key(|(link, _)| link.len())
1037 .map(|(_, code)| code.clone());
1038 (linked.is_some(), linked)
1039}
1040
1041#[allow(clippy::too_many_arguments)]
1047#[derive(Default)]
1050struct OwnMenu {
1051 nav_order: Option<String>,
1052 settings_order: Option<String>,
1053 moves: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
1054}
1055
1056impl OwnMenu {
1057 async fn load(state: &AdminState, user_id: i64) -> Self {
1058 let read = |key: String| async move {
1059 laterite_auth::store::user_preference(&state.db, user_id, &key)
1060 .await
1061 .unwrap_or_default()
1062 };
1063 let nav_order = read(nav::order_preference(nav::TOP_HIERARCHY)).await;
1064 let settings_order = read(nav::order_preference(settings::HIERARCHY)).await;
1065 let moves = read(nav::MOVES_PREFERENCE.to_string())
1066 .await
1067 .map(|json| nav::operator_moves(&json))
1068 .unwrap_or_default();
1069 Self {
1070 nav_order,
1071 settings_order,
1072 moves,
1073 }
1074 }
1075}
1076
1077fn own_tree(
1080 state: &AdminState,
1081 own: &OwnMenu,
1082) -> (Arc<nav::NavTree>, Arc<Vec<settings::SettingsItem>>) {
1083 if own.moves.is_empty() {
1084 return (Arc::clone(&state.nav), Arc::clone(&state.settings));
1085 }
1086 let mut items: Vec<nav::NavItem> = state.nav_items.as_ref().clone();
1087 let mut settings: Vec<settings::SettingsItem> = state.settings.as_ref().clone();
1088 nav::apply_moves(&mut items, &mut settings, &own.moves);
1089 match nav::NavTree::build_with(items, &state.admin_path, &state.order) {
1090 Ok(tree) => (Arc::new(tree), Arc::new(settings)),
1091 Err(e) => {
1092 tracing::warn!(error = %e, "an operator's menu moves could not be applied");
1093 (Arc::clone(&state.nav), Arc::clone(&state.settings))
1094 }
1095 }
1096}
1097
1098#[allow(clippy::too_many_arguments)]
1099fn resolve_nav_context(
1100 nav: &nav::NavTree,
1101 items: &[settings::SettingsItem],
1102 admin_path: &str,
1103 perms: &PermissionSet,
1104 own_order: Option<&str>,
1105 path: &str,
1106 tr: &Translator,
1107 icons: icons::Icons<'_>,
1108 order: &std::collections::BTreeMap<String, Vec<String>>,
1109 own_settings_order: Option<&str>,
1110) -> (
1111 Vec<nav::NavNode>,
1112 Vec<settings::CategoryView>,
1113 Option<String>,
1114) {
1115 let mut visible_nav = nav.visible(&|code| perms.allows(code));
1116 if let Some(order) = own_order {
1117 nav::apply_order(&mut visible_nav, order);
1118 }
1119 let own_sequence = own_settings_order.map(laterite_core::arrange::parse_sequence);
1120 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
1122 .into_iter()
1123 .filter(|i| !i.per_operator)
1124 .collect();
1125 let (in_context, active) = settings_context(&visible, admin_path, path);
1126 if in_context {
1127 let sidebar = settings::sidebar_groups(
1128 &visible,
1129 admin_path,
1130 active.as_deref(),
1131 tr,
1132 icons,
1133 order.get(settings::HIERARCHY).map(Vec::as_slice),
1134 own_sequence.as_deref(),
1135 );
1136 return (visible_nav, sidebar, Some("settings".to_string()));
1137 }
1138 let active_top = nav::active_top(&visible_nav, admin_path, path);
1139 let sidebar = active_top
1140 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
1141 .unwrap_or_default();
1142 let active_key = active_top.map(|node| node.item.key.clone());
1143 (visible_nav, sidebar, active_key)
1144}
1145
1146fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
1150 preference
1151 .and_then(|name| name.parse::<Tz>().ok())
1152 .unwrap_or(default_tz)
1153}
1154
1155fn zone_offered(name: &str) -> bool {
1157 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
1158}
1159
1160fn base_lang(tag: &str) -> String {
1163 tag.split(['-', '_'])
1164 .next()
1165 .unwrap_or(tag)
1166 .to_ascii_lowercase()
1167}
1168
1169fn is_serveable(base: &str, serveable: &[String]) -> bool {
1175 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
1176}
1177
1178fn default_locale(configured: &str, serveable: &[String]) -> String {
1181 let base = base_lang(configured);
1182 if is_serveable(&base, serveable) {
1183 base
1184 } else {
1185 "en".to_string()
1186 }
1187}
1188
1189fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
1193 let mut out = vec!["en".to_string()];
1194 out.extend(catalogs.locales());
1195 out
1196}
1197
1198fn locale_name(code: &str) -> String {
1203 if code == "en" {
1204 "English".to_string()
1205 } else {
1206 code.to_string()
1207 }
1208}
1209
1210fn parse_accept_language(header: &str) -> Vec<String> {
1213 let mut items: Vec<(f32, String)> = header
1214 .split(',')
1215 .filter_map(|part| {
1216 let mut bits = part.split(';');
1217 let tag = bits.next()?.trim();
1218 if tag.is_empty() || tag == "*" {
1219 return None;
1220 }
1221 let q = bits
1222 .find_map(|p| p.trim().strip_prefix("q="))
1223 .and_then(|q| q.parse::<f32>().ok())
1224 .unwrap_or(1.0);
1225 Some((q, tag.to_string()))
1226 })
1227 .collect();
1228 items.sort_by(|a, b| b.0.total_cmp(&a.0));
1230 items.into_iter().map(|(_, tag)| tag).collect()
1231}
1232
1233fn resolve_locale_chain(
1239 preference: Option<&str>,
1240 accept_language: Option<&str>,
1241 default: &str,
1242 serveable: &[String],
1243) -> Vec<String> {
1244 let mut chain: Vec<String> = Vec::new();
1245 let consider = |tag: &str, chain: &mut Vec<String>| {
1246 let base = base_lang(tag);
1247 if is_serveable(&base, serveable) && !chain.contains(&base) {
1248 chain.push(base);
1249 }
1250 };
1251 if let Some(pref) = preference {
1252 consider(pref, &mut chain);
1253 }
1254 if let Some(header) = accept_language {
1255 for tag in parse_accept_language(header) {
1256 consider(&tag, &mut chain);
1257 }
1258 }
1259 consider(default, &mut chain);
1260 consider("en", &mut chain);
1263 chain
1264}
1265
1266pub(crate) fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
1270 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
1271 let serveable = state.catalogs.locales();
1272 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
1273 Translator::with_chain(chain, state.catalogs.clone())
1274}
1275
1276#[derive(Serialize)]
1284#[non_exhaustive]
1285pub struct Resource {
1286 pub base_path: String,
1292 pub nav_label: Text,
1294 pub list: list::ListConfig,
1295 pub form: Option<form::FormConfig>,
1296 pub permission: Option<String>,
1303 pub import: Option<ImportConfig>,
1306 pub palette: Option<String>,
1309 pub relations: Vec<relation::RelationConfig>,
1312 pub menu: nav::Placement,
1315}
1316
1317#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1320#[non_exhaustive]
1321pub struct ImportConfig {
1322 #[serde(default)]
1325 pub permission: Option<String>,
1326}
1327
1328impl ImportConfig {
1329 pub fn new() -> Self {
1330 Self::default()
1331 }
1332
1333 pub fn permission(mut self, code: impl Into<String>) -> Self {
1336 self.permission = Some(code.into());
1337 self
1338 }
1339}
1340
1341#[derive(Clone, Serialize)]
1346#[non_exhaustive]
1347pub struct Permission {
1348 pub code: String,
1349 pub label: Text,
1351 pub group: Text,
1352 pub default_roles: Vec<String>,
1355 pub renamed_from: Vec<String>,
1358}
1359
1360pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
1361
1362impl Permission {
1363 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
1366 Self {
1367 code: code.into(),
1368 label: label.into(),
1369 group: group.into(),
1370 default_roles: Vec::new(),
1371 renamed_from: Vec::new(),
1372 }
1373 }
1374
1375 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
1381 where
1382 I: IntoIterator<Item = S>,
1383 S: Into<String>,
1384 {
1385 self.renamed_from = codes.into_iter().map(Into::into).collect();
1386 self
1387 }
1388
1389 pub fn roles<I, S>(mut self, roles: I) -> Self
1392 where
1393 I: IntoIterator<Item = S>,
1394 S: Into<String>,
1395 {
1396 self.default_roles = roles.into_iter().map(Into::into).collect();
1397 self
1398 }
1399}
1400
1401pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1407 let permissions: Vec<Permission> = builtin_permissions()
1408 .into_iter()
1409 .chain(contributed.iter().cloned())
1410 .collect();
1411 let mut out: Vec<(String, String)> = Vec::new();
1412 for permission in &permissions {
1413 for old in &permission.renamed_from {
1414 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1415 panic!(
1416 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1417 permission.code
1418 );
1419 }
1420 if permissions.iter().any(|p| &p.code == old) {
1421 panic!(
1422 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1423 permission.code
1424 );
1425 }
1426 out.push((old.clone(), permission.code.clone()));
1427 }
1428 }
1429 out
1430}
1431
1432pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1438 let permissions: Vec<Permission> = builtin_permissions()
1441 .into_iter()
1442 .chain(contributed.iter().cloned())
1443 .collect();
1444 let held = |role: &str| -> Vec<String> {
1445 permissions
1446 .iter()
1447 .filter(|p| {
1448 if p.default_roles.is_empty() {
1449 role == ROLE_ADMIN
1450 } else {
1451 p.default_roles.iter().any(|r| r == role)
1452 }
1453 })
1454 .map(|p| p.code.clone())
1455 .collect()
1456 };
1457 vec![
1458 laterite_auth::store::SystemRole {
1459 code: ROLE_ADMIN,
1460 name: "Administrator",
1461 description: "Administers the panel itself: operators, roles and settings.",
1462 permissions: held(ROLE_ADMIN),
1463 },
1464 laterite_auth::store::SystemRole {
1465 code: ROLE_EDITOR,
1466 name: "Editor",
1467 description: "Works with the content the application declares.",
1468 permissions: held(ROLE_EDITOR),
1469 },
1470 ]
1471}
1472
1473impl Resource {
1474 pub fn new(
1479 base_path: impl Into<String>,
1480 nav_label: impl Into<Text>,
1481 list: list::ListConfig,
1482 ) -> Self {
1483 Self {
1484 base_path: base_path.into(),
1485 nav_label: nav_label.into(),
1486 list,
1487 form: None,
1488 permission: None,
1489 import: None,
1490 palette: None,
1491 relations: Vec::new(),
1492 menu: nav::Placement::default(),
1493 }
1494 }
1495
1496 pub fn palette(mut self, source: impl Into<String>) -> Self {
1499 self.palette = Some(source.into());
1500 self
1501 }
1502
1503 pub fn menu(mut self, placement: nav::Placement) -> Self {
1505 self.menu = placement;
1506 self
1507 }
1508
1509 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1511 self.relations.push(relation);
1512 self
1513 }
1514
1515 pub fn import(mut self, import: ImportConfig) -> Self {
1517 self.import = Some(import);
1518 self
1519 }
1520
1521 pub fn form(mut self, form: form::FormConfig) -> Self {
1524 self.form = Some(form);
1525 self
1526 }
1527
1528 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1530 self.permission = Some(permission.into());
1531 self
1532 }
1533}
1534
1535struct SystemRoleGuard;
1541
1542#[laterite_core::strata::async_trait]
1543impl laterite_core::ModelListener for SystemRoleGuard {
1544 async fn before_delete(
1545 &self,
1546 cx: &mut laterite_core::SaveCx<'_>,
1547 rec: &laterite_core::Record,
1548 ) -> Result<(), Text> {
1549 let Some(id) = rec.id() else {
1550 return Ok(());
1551 };
1552 let backend = cx.backend();
1554 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1555 Ok(true) => Err(t!(
1556 "The framework maintains this role; it cannot be deleted."
1557 )),
1558 Err(_) => Err(t!(
1560 "Could not check whether this role is maintained by the framework."
1561 )),
1562 Ok(false) => Ok(()),
1563 }
1564 }
1565}
1566
1567fn builtin_permissions() -> Vec<Permission> {
1570 let mut out = vec![
1573 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1574 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1575 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1576 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1577 Permission::new(jobs::PERMISSION, "Manage jobs", "Backend"),
1578 Permission::new(scheduled::PERMISSION, "Manage scheduled tasks", "Backend"),
1579 Permission::new(settings::mail::PERMISSION, "Manage mail", "Backend"),
1580 Permission::new(
1581 mailtemplates::PERMISSION,
1582 "Manage mail templates",
1583 "Backend",
1584 ),
1585 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1586 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1587 Permission::new(
1588 "backend.view_request_log",
1589 "View the request log",
1590 "Backend",
1591 ),
1592 ];
1593 out.extend(medialib::permissions());
1594 out
1595}
1596
1597pub fn descriptor_sources() -> Vec<String> {
1603 let mut out = Vec::new();
1604 out.extend(laterite_core::collect_sources(&builtin_resources()));
1605 out.extend(laterite_core::collect_sources(&builtin_settings()));
1606 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1607 out.sort();
1608 out.dedup();
1609 out
1610}
1611
1612pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1627 builtin_modules().iter().map(|m| m.migrations()).collect()
1628}
1629
1630pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1634 vec![
1635 Box::new(laterite_auth::AuthModule),
1636 Box::new(settings::SettingsModule),
1637 Box::new(laterite_core::parameters::ParametersModule),
1638 Box::new(laterite_core::crypt::CryptModule),
1639 Box::new(laterite_core::cache::CacheModule),
1640 Box::new(laterite_core::queue::QueueModule),
1641 Box::new(laterite_core::schedule::ScheduleModule),
1642 Box::new(laterite_core::mail::MailModule),
1643 Box::new(requestlog::RequestLogModule),
1644 Box::new(plugins::PluginsModule),
1645 Box::new(laterite_media::MediaModule),
1646 Box::new(medialib::MediaLibraryModule),
1647 Box::new(import::ImportModule),
1648 ]
1649}
1650
1651#[derive(Default)]
1675pub struct Contributions {
1676 pub resources: Vec<Resource>,
1678 pub settings: Vec<settings::SettingsItem>,
1680 pub permissions: Vec<Permission>,
1682 pub picker_sources: Vec<picker::PickerSourceReg>,
1684 pub persisters: Vec<persist::PersisterReg>,
1686 pub listeners: Vec<laterite_core::ModelListenerReg>,
1688 pub field_types: Vec<field::FieldTypeReg>,
1692 pub column_types: Vec<list::ColumnTypeReg>,
1693 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1696 pub screens: Vec<routes::ScreenReg>,
1698 pub public_routes: Vec<routes::PublicRouteReg>,
1700 pub nav: Vec<nav::NavItem>,
1703 pub resource_hooks: Vec<ResourceHook>,
1706 pub widgets: Vec<widget::WidgetReg>,
1708 pub skins: Vec<skin::Skin>,
1710 pub assets: Vec<AssetReg>,
1712 pub plugin_defined: Arc<laterite_core::Registry>,
1717}
1718
1719pub fn router(
1720 auth: AuthService,
1721 db: Db,
1722 contributions: Contributions,
1723 config: AdminConfig,
1724 catalogs: Arc<CatalogStore>,
1725) -> Router {
1726 let Contributions {
1727 resources: app_resources,
1728 settings: app_settings,
1729 permissions: app_permissions,
1730 picker_sources: app_picker_sources,
1731 persisters: app_persisters,
1732 listeners: app_listeners,
1733 field_types: app_field_types,
1734 column_types: app_column_types,
1735 widgets: app_widgets,
1736 skins: app_skins,
1737 assets: app_assets,
1738 icons: app_icons,
1739 screens: app_screens,
1740 public_routes: app_public,
1741 nav: app_nav,
1742 resource_hooks,
1743 plugin_defined,
1744 } = contributions;
1745 let mut app_screens = app_screens;
1748 app_screens.push(medialib::screen());
1749 let mut app_public = app_public;
1750 app_public.push(medialib::serve_route());
1751 let admin_path = normalize_path(&config.path);
1752
1753 let mut resources = builtin_resources();
1754 let mut app_resources = app_resources;
1755 for hook in resource_hooks {
1758 let found = resources
1759 .iter_mut()
1760 .chain(app_resources.iter_mut())
1761 .find(|r| r.base_path == hook.target);
1762 match found {
1763 Some(resource) => {
1764 let changed = hook.apply(std::mem::replace(
1765 resource,
1766 Resource::new("", "", list::ListConfig::default()),
1767 ));
1768 *resource = changed;
1769 }
1770 None => panic!(
1771 "a resource hook targets `{}`, which no module declared",
1772 hook.target
1773 ),
1774 }
1775 }
1776 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1780 prefix_resource(&admin_path, resource);
1781 }
1782 for resource in app_resources.iter_mut() {
1786 if let Some(import) = &resource.import {
1787 let permission = import
1788 .permission
1789 .clone()
1790 .or_else(|| resource.permission.clone())
1791 .unwrap_or_else(|| {
1792 panic!(
1793 "admin resource `{}` declares `import` but no permission; importing \
1794 writes records, so it must be gated",
1795 resource.base_path
1796 )
1797 });
1798 resource.list.import = Some(routes::feature_link(
1799 &resource.base_path,
1800 "import",
1801 &permission,
1802 ));
1803 }
1804 }
1805
1806 let mut settings = builtin_settings();
1807 settings.extend(app_settings);
1808 let mut app_listeners = app_listeners;
1811 app_listeners.insert(
1812 0,
1813 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1814 );
1815
1816 let mut permissions = builtin_permissions();
1817 permissions.extend(app_permissions);
1818 let permission_codes: std::collections::HashSet<String> =
1820 permissions.iter().map(|p| p.code.clone()).collect();
1821
1822 let mut nav_items = vec![
1828 nav::NavItem::new("dashboard", "Dashboard", "")
1829 .icon("layout-dashboard")
1830 .slot(laterite_core::arrange::slot::HOME),
1831 nav::NavItem::new("settings", "Settings", "/settings")
1832 .icon("settings")
1833 .slot(laterite_core::arrange::slot::SYSTEM),
1834 ];
1835 nav_items.extend(app_nav);
1836 for resource in &app_resources {
1837 if resource.menu.hidden {
1838 continue;
1839 }
1840 let unprefixed = resource
1841 .base_path
1842 .strip_prefix(admin_path.as_str())
1843 .unwrap_or(&resource.base_path)
1844 .to_string();
1845 let mut item = nav::NavItem::new(
1846 format!("resource:{unprefixed}"),
1847 resource.nav_label.clone(),
1848 unprefixed,
1849 )
1850 .slot(resource.menu.slot)
1851 .order(resource.menu.order);
1852 item.icon = resource.menu.icon.clone();
1853 item.permission = resource.permission.clone();
1854 item.under = resource.menu.under.clone();
1855 item.after = resource.menu.after.clone();
1856 item.before = resource.menu.before.clone();
1857 nav_items.push(item);
1858 }
1859 for reg in &app_screens {
1860 let Some(label) = ®.nav_label else {
1861 continue;
1862 };
1863 if reg.menu.hidden {
1864 continue;
1865 }
1866 let mut item = nav::NavItem::new(
1867 format!("screen:{}", reg.base_path),
1868 label.clone(),
1869 reg.base_path.clone(),
1870 )
1871 .permission(reg.permission.clone())
1872 .slot(reg.menu.slot)
1873 .order(reg.menu.order);
1874 item.icon = reg.menu.icon.clone();
1875 item.under = reg.menu.under.clone();
1876 item.after = reg.menu.after.clone();
1877 item.before = reg.menu.before.clone();
1878 nav_items.push(item);
1879 }
1880 nav::apply_moves(&mut nav_items, &mut settings, &config.menu);
1883 let nav = nav::NavTree::build_with(nav_items.clone(), &admin_path, &config.order)
1884 .unwrap_or_else(|e| panic!("admin menu: {e}"));
1885 nav.log_arrangement();
1886 settings::log_arrangement(
1887 &settings,
1888 config.order.get(settings::HIERARCHY).map(Vec::as_slice),
1889 );
1890 let palette_sources: Vec<palette::RecordSource> = app_resources
1893 .iter()
1894 .filter_map(|r| {
1895 r.palette.as_ref().map(|source| palette::RecordSource {
1896 label: r.nav_label.clone(),
1897 source: source.clone(),
1898 base_path: r.base_path.clone(),
1899 permission: r.permission.clone(),
1900 })
1901 })
1902 .collect();
1903 resources.extend(app_resources);
1904
1905 let app_name = if config.app_name.trim().is_empty() {
1906 "Laterite".to_string()
1907 } else {
1908 config.app_name.clone()
1909 };
1910
1911 let mut pickers = picker::PickerRegistry::new();
1914 for reg in app_picker_sources {
1915 let name = reg.name.clone();
1916 if !field::is_name(&name, true) {
1917 panic!("invalid picker source name `{name}`");
1918 }
1919 if pickers.insert(name.clone(), reg).is_some() {
1920 panic!("duplicate picker source `{name}`");
1921 }
1922 }
1923
1924 for source in &palette_sources {
1928 if !pickers.contains_key(&source.source) {
1929 panic!(
1930 "admin resource `{}` names palette source `{}`, which no module registered",
1931 source.base_path, source.source
1932 );
1933 }
1934 }
1935 let pickers = Arc::new(pickers);
1936 let crypt: Arc<laterite_core::Crypt> = config.crypt.clone().unwrap_or_else(|| {
1939 Arc::new(laterite_core::Crypt::new(
1940 laterite_core::MasterKey::derived(0, &config.secret),
1941 Vec::new(),
1942 ))
1943 });
1944 let mut field_types = field::builtin_registry();
1945 field_types.insert(
1946 "sensitive".to_string(),
1947 Arc::new(field::SensitiveField::new(crypt.clone())),
1948 );
1949 field_types.insert(
1950 "reference".to_string(),
1951 Arc::new(field::RefPickerField::new(pickers.clone())),
1952 );
1953
1954 let mut persisters = persist::PersisterRegistry::new();
1957 for reg in app_persisters {
1958 let name = reg.name.clone();
1959 if !field::is_name(&name, true) {
1960 panic!("invalid persister name `{name}`");
1961 }
1962 if persisters.insert(name.clone(), reg.persister).is_some() {
1963 panic!("duplicate persister `{name}`");
1964 }
1965 }
1966
1967 for reg in app_field_types {
1973 let name = reg.name().to_string();
1974 if !field::is_name(&name, true) {
1975 panic!("invalid field type name `{name}`");
1976 }
1977 if field_types.insert(name.clone(), reg.field_type).is_some() {
1978 panic!("duplicate field type `{name}`");
1979 }
1980 }
1981
1982 let mut icon_set = laterite_core::icons::IconSet::new();
1986 for (owner, reg) in app_icons {
1987 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1988 panic!("module `{owner}` contributed an invalid icon: {e}");
1989 }
1990 }
1991
1992 let mut assets = builtin_assets();
1995 assets.insert(
1996 ICON_SPRITE,
1997 AdminAsset {
1998 mime: "image/svg+xml",
1999 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
2000 },
2001 );
2002 for reg in app_assets {
2006 let key = reg.key;
2007 if key.is_empty()
2008 || key.starts_with('/')
2009 || key.split('/').any(|s| s.is_empty() || s == "..")
2010 {
2011 panic!("invalid admin asset key `{key}`");
2012 }
2013 if assets.insert(key, reg.asset).is_some() {
2014 panic!("duplicate admin asset `{key}`");
2015 }
2016 }
2017 let mut skins = skin::builtin();
2018 skins.extend(app_skins);
2019 skin::check(&skins);
2020
2021 for item in &settings {
2025 icons::require(
2026 &icon_set,
2027 &format!("settings item `{}`", item.code),
2028 item.icon.as_deref(),
2029 );
2030 }
2031 for node in nav
2032 .top
2033 .iter()
2034 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
2035 {
2036 icons::require(
2037 &icon_set,
2038 &format!("menu entry `{}`", node.item.key),
2039 node.item.icon.as_deref(),
2040 );
2041 }
2042
2043 let mut column_types = list::builtin_column_registry();
2047 for reg in app_column_types {
2048 let name = reg.name().to_string();
2049 if !field::is_name(&name, true) {
2050 panic!("invalid column type name `{name}`");
2051 }
2052 if column_types.insert(name.clone(), reg.column_type).is_some() {
2053 panic!("duplicate column type `{name}`");
2054 }
2055 }
2056
2057 let settings = Arc::new(settings);
2058 let asset_urls_arc = Arc::new(asset_urls(&assets));
2060 let active_skin = Arc::new(skin::resolve(
2061 &config.skin,
2062 &skins,
2063 &admin_path,
2064 &asset_urls_arc,
2065 ));
2066 let sprite_url: Arc<str> = Arc::from(
2067 asset_urls_arc
2068 .get(ICON_SPRITE)
2069 .map(|p| format!("{admin_path}/assets/{p}"))
2070 .unwrap_or_default()
2071 .as_str(),
2072 );
2073 let icons_arc = Arc::new(icon_set);
2074 let mut widget_regs = widgets::builtin();
2075 for reg in app_widgets {
2076 if let Some(at) = widget_regs.iter().position(|w| w.code() == reg.code()) {
2077 widget_regs[at] = reg;
2078 } else {
2079 widget_regs.push(reg);
2080 }
2081 }
2082 let cache = config
2083 .cache
2084 .clone()
2085 .unwrap_or_else(laterite_core::Cache::local);
2086 let queue = config
2087 .queue
2088 .clone()
2089 .unwrap_or_else(|| inline_queue(&db, cache.clone()));
2090 let schedule = config
2091 .schedule
2092 .clone()
2093 .unwrap_or_else(|| empty_schedule(&db, cache.clone(), queue.clone()));
2094 let mail = config
2095 .mail
2096 .clone()
2097 .unwrap_or_else(|| stub_mail(&db, cache.clone(), queue.clone()));
2098 let state = AdminState {
2099 auth,
2100 parameters: laterite_core::parameters::Parameters::with_cache(db.clone(), cache.clone()),
2101 cache,
2102 queue,
2103 schedule,
2104 mail,
2105 mail_config: Arc::new(config.mail_config.clone()),
2106 upload_extensions: Arc::new(upload_extensions(&config.upload_extensions)),
2107 db,
2108 nav: Arc::new(nav),
2109 nav_items: Arc::new(nav_items),
2110 settings: settings.clone(),
2111 permissions: Arc::new(permissions),
2112 admin_path: Arc::from(admin_path.as_str()),
2113 secure_cookie: config.secure_cookie,
2114 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
2115 icons: icons_arc.clone(),
2116 sprite_url: sprite_url.clone(),
2117 origin: Arc::from(config.origin.trim_end_matches('/')),
2118 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
2119 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
2120 catalogs,
2121 app_name,
2122 skin: active_skin,
2123 field_types: Arc::new(field_types),
2124 column_types: Arc::new(column_types),
2125 widgets: Arc::new(widget_regs),
2126 plugin_defined,
2127 storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
2128 crypt,
2129 order: config.order.clone(),
2130 cookie_path: if app_public.iter().any(|r| r.is_site()) {
2131 Arc::from("/")
2132 } else {
2133 Arc::from(admin_path.as_str())
2134 },
2135 assets: Arc::new(assets),
2136 asset_urls: asset_urls_arc.clone(),
2137 pickers,
2138 palette: Arc::new(palette_sources),
2139 overrides: Arc::new(field::NoOverrides),
2140 };
2141
2142 let mut protected = Router::new().route(&admin_path, get(dashboard));
2143 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
2147 for resource in &resources {
2148 let (mounted, prepared) = mount_resource(
2149 resource,
2150 &state.field_types,
2151 &state.column_types,
2152 &permission_codes,
2153 &persisters,
2154 &app_listeners,
2155 );
2156 protected = protected.merge(mounted);
2157 if let Some(prepared) = prepared {
2158 let key = resource
2159 .base_path
2160 .strip_prefix(admin_path.as_str())
2161 .unwrap_or(&resource.base_path);
2162 forms.insert(key.to_string(), prepared);
2163 }
2164 }
2165 let forms: form::Forms = Arc::new(forms);
2166 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
2170 .plugin_defined
2171 .items::<laterite_media::DiskReg>()
2172 .into_iter()
2173 .find(|d| d.name == import::DISK)
2174 .map(|d| d.driver.clone())
2175 .unwrap_or_else(|| {
2176 Arc::new(laterite_media::LocalDisk::new(format!(
2177 "{}/{}",
2178 config.storage_dir.trim_end_matches('/'),
2179 import::DISK
2180 )))
2181 });
2182 for resource in &resources {
2183 let Some(link) = &resource.list.import else {
2184 continue;
2185 };
2186 let key = resource
2187 .base_path
2188 .strip_prefix(admin_path.as_str())
2189 .unwrap_or(&resource.base_path)
2190 .to_string();
2191 let prepared = forms.get(&key).unwrap_or_else(|| {
2192 panic!(
2193 "admin resource `{}` declares `import` but has no form to import through",
2194 resource.base_path
2195 )
2196 });
2197 let feature = import::ImportFeature {
2198 disk: private_disk.clone(),
2199 };
2200 let base = link.href.clone();
2201 let ctx = routes::RouteCtx::new(
2202 state.db.clone(),
2203 state.auth.clone(),
2204 state.parameters.clone(),
2205 state.queue.clone(),
2206 state.schedule.clone(),
2207 state.mail.clone(),
2208 state.upload_extensions.clone(),
2209 state.field_types.clone(),
2210 state.overrides.clone(),
2211 state.crypt.clone(),
2212 &state.storage_dir,
2213 &base,
2214 &admin_path,
2215 &state.origin,
2216 state.plugin_defined.clone(),
2217 state.auth.events().clone(),
2218 forms.clone(),
2219 );
2220 let info = routes::ResourceInfo::new(
2221 key,
2222 resource.base_path.clone(),
2223 resource.nav_label.clone(),
2224 link.permission.clone(),
2225 form::FormHandle::new(state.db.clone(), state.cache.clone(), prepared.clone()),
2226 );
2227 protected = protected.merge(guard_with_permission(
2228 Router::new()
2229 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
2230 &link.permission,
2231 ));
2232 }
2233 state
2236 .queue
2237 .register(import::run::job_reg(forms.clone(), private_disk.clone()));
2238 for reg in &app_screens {
2242 let base = format!("{admin_path}{}", reg.base_path);
2243 let ctx = routes::RouteCtx::new(
2244 state.db.clone(),
2245 state.auth.clone(),
2246 state.parameters.clone(),
2247 state.queue.clone(),
2248 state.schedule.clone(),
2249 state.mail.clone(),
2250 state.upload_extensions.clone(),
2251 state.field_types.clone(),
2252 state.overrides.clone(),
2253 state.crypt.clone(),
2254 &state.storage_dir,
2255 &base,
2256 &admin_path,
2257 &state.origin,
2258 state.plugin_defined.clone(),
2259 state.auth.events().clone(),
2260 forms.clone(),
2261 );
2262 protected = protected.merge(guard_with_permission(
2263 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
2264 ®.permission,
2265 ));
2266 }
2267
2268 protected = protected.merge(guard_with_permission(
2271 Router::new()
2272 .route(
2273 &format!("{admin_path}/roles/new"),
2274 get(roles::new_form).post(roles::create),
2275 )
2276 .route(
2277 &format!("{admin_path}/roles/{{id}}/edit"),
2278 get(roles::edit_form).post(roles::update),
2279 ),
2280 "backend.manage_roles",
2281 ));
2282 protected = protected.merge(guard_with_permission(
2285 Router::new()
2286 .route(
2287 &format!("{admin_path}/users/new"),
2288 get(users::new_form).post(users::create),
2289 )
2290 .route(
2291 &format!("{admin_path}/users/{{id}}/edit"),
2292 get(users::edit_form).post(users::update),
2293 )
2294 .route(
2295 &format!("{admin_path}/users/{{id}}/active"),
2296 post(users::set_active),
2297 )
2298 .route(
2299 &format!("{admin_path}/users/{{id}}/password"),
2300 post(users::reset_password),
2301 )
2302 .route(
2303 &format!("{admin_path}/users/{{id}}/unlock"),
2304 post(users::unlock),
2305 ),
2306 "backend.manage_users",
2307 ));
2308 protected = protected.merge(guard_with_permission(
2311 Router::new()
2312 .route(&format!("{admin_path}/plugins"), get(plugins::index))
2313 .route(
2314 &format!("{admin_path}/plugins/toggle"),
2315 post(plugins::toggle),
2316 ),
2317 plugins::MANAGE_PERMISSION,
2318 ));
2319 protected = protected.merge(guard_with_permission(
2321 Router::new()
2322 .route(&format!("{admin_path}/jobs"), get(jobs::index))
2323 .route(
2324 &format!("{admin_path}/jobs/retry-all"),
2325 post(jobs::retry_all),
2326 )
2327 .route(
2328 &format!("{admin_path}/jobs/forget-all"),
2329 post(jobs::forget_all),
2330 )
2331 .route(
2332 &format!("{admin_path}/jobs/{{id}}/retry"),
2333 post(jobs::retry),
2334 )
2335 .route(
2336 &format!("{admin_path}/jobs/{{id}}/forget"),
2337 post(jobs::forget),
2338 ),
2339 jobs::PERMISSION,
2340 ));
2341 protected = protected.merge(guard_with_permission(
2342 Router::new()
2343 .route(
2344 &format!("{admin_path}{}", settings::mail::TEST_PATH),
2345 post(settings::mail::send_test),
2346 )
2347 .route(&format!("{admin_path}/mail-log"), get(maillog::index))
2348 .route(
2349 &format!("{admin_path}/mail-log/clear"),
2350 post(maillog::clear),
2351 )
2352 .route(&format!("{admin_path}/mail-log/{{id}}"), get(maillog::view))
2353 .route(
2354 &format!("{admin_path}/mail-log/{{id}}/html"),
2355 get(maillog::html),
2356 ),
2357 settings::mail::PERMISSION,
2358 ));
2359 protected = protected.merge(guard_with_permission(
2360 Router::new()
2361 .route(
2362 &format!("{admin_path}/mail-templates"),
2363 get(mailtemplates::index),
2364 )
2365 .route(
2366 &format!("{admin_path}/mail-templates/brand-preview"),
2367 post(mailtemplates::brand_preview),
2368 )
2369 .route(
2370 &format!("{admin_path}/mail-templates/{{kind}}/new"),
2371 get(mailtemplates::new_form).post(mailtemplates::create),
2372 )
2373 .route(
2374 &format!("{admin_path}/mail-templates/{{kind}}/{{code}}"),
2375 get(mailtemplates::edit).post(mailtemplates::save),
2376 )
2377 .route(
2378 &format!("{admin_path}/mail-templates/{{kind}}/{{code}}/preview"),
2379 get(mailtemplates::preview),
2380 )
2381 .route(
2382 &format!("{admin_path}/mail-templates/{{kind}}/{{code}}/test"),
2383 post(mailtemplates::test),
2384 )
2385 .route(
2386 &format!("{admin_path}/mail-templates/{{kind}}/{{code}}/reset"),
2387 post(mailtemplates::reset),
2388 )
2389 .route(
2390 &format!("{admin_path}/mail-templates/{{kind}}/{{code}}/delete"),
2391 post(mailtemplates::delete),
2392 ),
2393 mailtemplates::PERMISSION,
2394 ));
2395 protected = protected.merge(guard_with_permission(
2396 Router::new()
2397 .route(
2398 &format!("{admin_path}/scheduled-tasks"),
2399 get(scheduled::index),
2400 )
2401 .route(
2402 &format!("{admin_path}/scheduled-tasks/{{name}}/run"),
2403 post(scheduled::run_now),
2404 ),
2405 scheduled::PERMISSION,
2406 ));
2407 protected = protected
2408 .route(&format!("{admin_path}/settings"), get(settings_index))
2409 .route(
2410 &format!("{admin_path}/preferences"),
2411 get(preferences_form).post(preferences_update),
2412 )
2413 .route(
2414 &format!("{admin_path}/preferences/password"),
2415 post(password_update),
2416 )
2417 .route(
2418 &format!("{admin_path}/preferences/menu"),
2419 post(menu_order_update),
2420 )
2421 .route(
2422 &format!("{admin_path}/preferences/menu/moves"),
2423 post(menu_moves_update),
2424 )
2425 .route(
2426 &format!("{admin_path}/markdown/preview"),
2427 post(markdown_preview),
2428 )
2429 .route(&format!("{admin_path}/editor/menu"), get(editor_menu))
2430 .route(
2431 &format!("{admin_path}/dashboard/widget/{{code}}"),
2432 get(dashboard_widget),
2433 )
2434 .route(
2435 &format!("{admin_path}/dashboard/layout"),
2436 post(dashboard_layout_update),
2437 )
2438 .route(
2439 &format!("{admin_path}/dashboard/layout/reset"),
2440 post(dashboard_layout_reset),
2441 )
2442 .route(&format!("{admin_path}/palette"), get(palette::palette))
2443 .route(
2444 &format!("{admin_path}/preferences/sessions/revoke"),
2445 post(session_revoke),
2446 )
2447 .route(
2448 &format!("{admin_path}/preferences/sessions/revoke-others"),
2449 post(session_revoke_others),
2450 )
2451 .route(
2454 &format!("{admin_path}/pickers/{{source}}/search"),
2455 any(picker::search),
2456 )
2457 .route(
2458 &format!("{admin_path}/pickers/{{source}}/resolve"),
2459 any(picker::resolve),
2460 )
2461 .route(&format!("{admin_path}/logout"), post(logout));
2462
2463 for item in settings.iter().filter(|i| i.link.is_none()) {
2467 let path = format!("{admin_path}{}", item.route());
2468 let code = item.code.clone();
2469 let edit_code = code.clone();
2470 protected = protected.route(
2471 &path,
2472 get(
2473 move |State(state): State<AdminState>,
2474 Extension(shell): Extension<Shell>,
2475 Extension(user): Extension<AuthenticatedUser>| {
2476 let code = edit_code.clone();
2477 async move { settings_edit(state, shell, user, code).await }
2478 },
2479 )
2480 .post(
2481 move |State(state): State<AdminState>,
2482 Extension(shell): Extension<Shell>,
2483 Extension(user): Extension<AuthenticatedUser>,
2484 Extension(session): Extension<session::SessionHandle>,
2485 Form(data): Form<HashMap<String, String>>| {
2486 let code = code.clone();
2487 async move { settings_update(state, shell, user, session, code, data).await }
2488 },
2489 ),
2490 );
2491 }
2492
2493 let app = protected
2494 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
2495 .route(
2499 &format!("{admin_path}/login"),
2500 get(login_form).post(login_submit),
2501 )
2502 .route(
2503 &format!("{admin_path}/forgot"),
2504 get(passwordlinks::forgot_form).post(passwordlinks::forgot_submit),
2505 )
2506 .route(
2507 &format!("{admin_path}/set-password/{{token}}"),
2508 get(passwordlinks::set_password_form).post(passwordlinks::set_password_submit),
2509 )
2510 .route(
2511 &format!("{admin_path}/setup"),
2512 get(setup_form).post(setup_submit),
2513 )
2514 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
2515 .merge(mount_public(
2519 &app_public,
2520 &state.db,
2521 &state.auth,
2522 &state.parameters,
2523 &state.queue,
2524 &state.schedule,
2525 &state.mail,
2526 &state.upload_extensions,
2527 &state.field_types,
2528 &state.overrides,
2529 &state.crypt,
2530 &state.storage_dir,
2531 &admin_path,
2532 &state.origin,
2533 &state.plugin_defined,
2534 state.auth.events(),
2535 &forms,
2536 ));
2537 let app = match app_public.iter().find(|r| r.is_site()) {
2540 Some(site) => {
2541 let ctx = routes::RouteCtx::new(
2542 state.db.clone(),
2543 state.auth.clone(),
2544 state.parameters.clone(),
2545 state.queue.clone(),
2546 state.schedule.clone(),
2547 state.mail.clone(),
2548 state.upload_extensions.clone(),
2549 state.field_types.clone(),
2550 state.overrides.clone(),
2551 state.crypt.clone(),
2552 &state.storage_dir,
2553 "/",
2554 &admin_path,
2555 &state.origin,
2556 state.plugin_defined.clone(),
2557 state.auth.events().clone(),
2558 forms.clone(),
2559 );
2560 app.fallback_service(site.route.mount(&ctx))
2561 }
2562 None => app.fallback(not_found_fallback),
2563 };
2564 let secure = state.secure_cookie;
2565 app
2566 .layer(middleware::from_fn_with_state(
2569 state.clone(),
2570 enforce_origin,
2571 ))
2572 .layer(middleware::from_fn_with_state(
2575 state.clone(),
2576 capture_client,
2577 ))
2578 .layer(
2583 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2584 header::CACHE_CONTROL,
2585 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
2586 ),
2587 )
2588 .layer(
2592 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2593 header::VARY,
2594 axum::http::HeaderValue::from_static("HX-Request"),
2595 ),
2596 )
2597 .layer(
2603 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2604 header::CONTENT_SECURITY_POLICY,
2605 axum::http::HeaderValue::from_static(CONTENT_SECURITY_POLICY),
2606 ),
2607 )
2608 .layer(
2609 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2610 header::X_CONTENT_TYPE_OPTIONS,
2611 axum::http::HeaderValue::from_static("nosniff"),
2612 ),
2613 )
2614 .layer(
2615 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2616 header::X_FRAME_OPTIONS,
2617 axum::http::HeaderValue::from_static("SAMEORIGIN"),
2618 ),
2619 )
2620 .layer(
2621 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2622 header::REFERRER_POLICY,
2623 axum::http::HeaderValue::from_static("same-origin"),
2624 ),
2625 )
2626 .layer(
2627 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2628 axum::http::HeaderName::from_static("permissions-policy"),
2629 axum::http::HeaderValue::from_static(
2630 "camera=(), microphone=(), geolocation=(), payment=()",
2631 ),
2632 ),
2633 )
2634 .layer(
2635 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2636 axum::http::HeaderName::from_static("cross-origin-opener-policy"),
2637 axum::http::HeaderValue::from_static("same-origin"),
2638 ),
2639 )
2640 .layer(
2643 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2644 header::STRICT_TRANSPORT_SECURITY,
2645 if secure {
2646 Some(axum::http::HeaderValue::from_static(
2647 "max-age=31536000; includeSubDomains",
2648 ))
2649 } else {
2650 None
2651 },
2652 ),
2653 )
2654 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
2655 handle_panic,
2656 ))
2657 .with_state(state)
2658}
2659
2660pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self'; connect-src 'self' https:; frame-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'";
2664
2665async fn not_found_fallback(
2668 State(state): State<AdminState>,
2669 method: axum::http::Method,
2670 uri: axum::http::Uri,
2671) -> AdminError {
2672 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
2673 AdminError::NotFound
2674}
2675
2676async fn not_found_plain() -> AdminError {
2678 AdminError::NotFound
2679}
2680
2681fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
2683 let cause = err
2684 .downcast_ref::<&str>()
2685 .map(|s| s.to_string())
2686 .or_else(|| err.downcast_ref::<String>().cloned())
2687 .unwrap_or_else(|| "unknown panic".to_string());
2688 tracing::error!(panic = cause, "admin handler panicked");
2689 error::masked_500()
2690}
2691
2692pub fn normalize_path(path: &str) -> String {
2697 let trimmed = path.trim().trim_matches('/');
2698 if trimmed.is_empty() {
2699 "/admin".to_string()
2700 } else {
2701 format!("/{trimmed}")
2702 }
2703}
2704
2705#[allow(clippy::too_many_arguments)]
2709fn mount_public(
2710 regs: &[routes::PublicRouteReg],
2711 db: &Db,
2712 auth: &AuthService,
2713 parameters: &laterite_core::parameters::Parameters,
2714 queue: &laterite_core::Queue,
2715 schedule: &laterite_core::Schedule,
2716 mail: &laterite_core::Mail,
2717 upload_extensions: &Arc<Vec<String>>,
2718 field_types: &Arc<field::FieldRegistry>,
2719 overrides: &Arc<dyn field::OverrideResolver>,
2720 crypt: &Arc<laterite_core::Crypt>,
2721 storage_dir: &str,
2722 admin_path: &str,
2723 origin: &str,
2724 plugin_defined: &Arc<laterite_core::Registry>,
2725 events: &laterite_core::Events,
2726 forms: &form::Forms,
2727) -> Router<AdminState> {
2728 let mut router = Router::new();
2729 for reg in regs.iter().filter(|r| !r.is_site()) {
2731 let ctx = routes::RouteCtx::new(
2732 db.clone(),
2733 auth.clone(),
2734 parameters.clone(),
2735 queue.clone(),
2736 schedule.clone(),
2737 mail.clone(),
2738 upload_extensions.clone(),
2739 field_types.clone(),
2740 overrides.clone(),
2741 crypt.clone(),
2742 storage_dir,
2743 ®.path,
2744 admin_path,
2745 origin,
2746 plugin_defined.clone(),
2747 events.clone(),
2748 forms.clone(),
2749 );
2750 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2755 }
2756 router
2757}
2758
2759pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2762 let old = resource.base_path.clone();
2763 resource.base_path = base.to_string();
2764 if let Some(edit_base) = &mut resource.list.edit_base {
2765 if edit_base == &old {
2766 *edit_base = base.to_string();
2767 }
2768 }
2769 if let Some(form) = &mut resource.form {
2770 if form.base_path == old {
2771 form.base_path = base.to_string();
2772 }
2773 }
2774}
2775
2776fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2777 resource.base_path = format!("{admin_path}{}", resource.base_path);
2778 if let Some(edit_base) = &mut resource.list.edit_base {
2779 *edit_base = format!("{admin_path}{edit_base}");
2780 }
2781 if let Some(form) = &mut resource.form {
2782 form.base_path = format!("{admin_path}{}", form.base_path);
2783 }
2784}
2785
2786#[derive(Debug, Clone)]
2790pub struct AdminAsset {
2791 pub mime: &'static str,
2792 pub bytes: std::borrow::Cow<'static, [u8]>,
2796}
2797
2798impl AdminAsset {
2799 pub fn new(mime: &'static str, bytes: &'static [u8]) -> Self {
2801 Self {
2802 mime,
2803 bytes: std::borrow::Cow::Borrowed(bytes),
2804 }
2805 }
2806
2807 pub fn css(bytes: &'static [u8]) -> Self {
2809 Self::new("text/css; charset=utf-8", bytes)
2810 }
2811
2812 pub fn js(bytes: &'static [u8]) -> Self {
2814 Self::new("text/javascript; charset=utf-8", bytes)
2815 }
2816}
2817
2818#[derive(Debug, Clone)]
2820pub struct AssetReg {
2821 pub key: &'static str,
2822 pub asset: AdminAsset,
2823}
2824
2825pub(crate) fn upload_extensions(configured: &[String]) -> Vec<String> {
2829 let listed: Vec<String> = configured
2830 .iter()
2831 .map(|e| e.trim().trim_start_matches('.').to_ascii_lowercase())
2832 .filter(|e| !e.is_empty())
2833 .collect();
2834 if listed.is_empty() {
2835 laterite_media::sanitize::DEFAULT_EXTENSIONS
2836 .iter()
2837 .map(|e| e.to_string())
2838 .collect()
2839 } else {
2840 listed
2841 }
2842}
2843
2844pub const SETUP_COMPLETED_AT: &str = "laterite.backend::setup.completed_at";
2849
2850pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2853
2854pub(crate) type AssetUrls = HashMap<&'static str, String>;
2858
2859pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2861 registry
2862 .iter()
2863 .map(|(&key, asset)| {
2864 (
2865 key,
2866 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2867 )
2868 })
2869 .collect()
2870}
2871
2872pub(crate) fn page_assets(
2879 keys: &[&str],
2880 base: &str,
2881 registry: &AssetRegistry,
2882 urls: &AssetUrls,
2883) -> Vec<PageAsset> {
2884 let mut seen = std::collections::HashSet::new();
2885 let mut out = Vec::new();
2886 for &key in keys {
2887 if !seen.insert(key) {
2888 continue;
2889 }
2890 match (registry.get(key), urls.get(key)) {
2891 (Some(asset), Some(path)) => out.push(PageAsset {
2892 url: format!("{base}/assets/{path}"),
2893 css: asset.mime.contains("css"),
2894 }),
2895 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2896 }
2897 }
2898 out
2899}
2900
2901pub const CODE_EDITOR_ASSETS: &[&str] = &[
2907 "vendor/codeeditor.js",
2908 "fields/codeeditor.js",
2909 "fields/codeeditor.css",
2910];
2911
2912pub const RICH_EDITOR_ASSETS: &[&str] = &[
2916 "vendor/richeditor.js",
2917 "vendor/richeditor.css",
2918 "fields/richeditor.js",
2919 "fields/richeditor.css",
2920];
2921
2922pub const MARKDOWN_ASSETS: &[&str] = &[
2926 "vendor/codeeditor.js",
2927 "fields/codeeditor.css",
2928 "fields/markdown.js",
2929 "fields/markdown.css",
2930];
2931
2932pub(crate) fn builtin_assets() -> AssetRegistry {
2943 let font = |bytes: &'static [u8]| AdminAsset {
2944 mime: "font/woff2",
2945 bytes: std::borrow::Cow::Borrowed(bytes),
2946 };
2947 HashMap::from([
2948 (
2949 "laterite.css",
2950 AdminAsset {
2951 mime: "text/css; charset=utf-8",
2952 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2953 },
2954 ),
2955 (
2956 "laterite.js",
2957 AdminAsset {
2958 mime: "text/javascript; charset=utf-8",
2959 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2960 },
2961 ),
2962 (
2963 skin::COMPACT_STYLESHEET,
2964 AdminAsset::css(include_bytes!("../assets/skins/compact.css")),
2965 ),
2966 (
2967 "theme-boot.js",
2968 AdminAsset {
2969 mime: "text/javascript; charset=utf-8",
2970 bytes: std::borrow::Cow::Borrowed(laterite_core::theme::BOOT_JS.as_bytes()),
2971 },
2972 ),
2973 (
2974 "vendor/htmx.min.js",
2975 AdminAsset {
2976 mime: "text/javascript; charset=utf-8",
2977 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2978 },
2979 ),
2980 (
2981 "vendor/richeditor.js",
2982 AdminAsset {
2983 mime: "text/javascript; charset=utf-8",
2984 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/richeditor.js")),
2985 },
2986 ),
2987 (
2988 "vendor/richeditor.css",
2989 AdminAsset {
2990 mime: "text/css; charset=utf-8",
2991 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2992 "../assets/vendor/richeditor.css"
2993 )),
2994 },
2995 ),
2996 (
2997 "vendor/codeeditor.js",
2998 AdminAsset {
2999 mime: "text/javascript; charset=utf-8",
3000 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/codeeditor.js")),
3001 },
3002 ),
3003 (
3004 "fields/richeditor.js",
3005 AdminAsset {
3006 mime: "text/javascript; charset=utf-8",
3007 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/richeditor.js")),
3008 },
3009 ),
3010 (
3011 "fields/richeditor.css",
3012 AdminAsset {
3013 mime: "text/css; charset=utf-8",
3014 bytes: std::borrow::Cow::Borrowed(include_bytes!(
3015 "../assets/fields/richeditor.css"
3016 )),
3017 },
3018 ),
3019 (
3020 "fields/markdown.js",
3021 AdminAsset {
3022 mime: "text/javascript; charset=utf-8",
3023 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.js")),
3024 },
3025 ),
3026 (
3027 "fields/markdown.css",
3028 AdminAsset {
3029 mime: "text/css; charset=utf-8",
3030 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.css")),
3031 },
3032 ),
3033 (
3034 "fields/codeeditor.js",
3035 AdminAsset {
3036 mime: "text/javascript; charset=utf-8",
3037 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/codeeditor.js")),
3038 },
3039 ),
3040 (
3041 "fields/codeeditor.css",
3042 AdminAsset {
3043 mime: "text/css; charset=utf-8",
3044 bytes: std::borrow::Cow::Borrowed(include_bytes!(
3045 "../assets/fields/codeeditor.css"
3046 )),
3047 },
3048 ),
3049 (
3050 "fields/ref-picker.js",
3051 AdminAsset {
3052 mime: "text/javascript; charset=utf-8",
3053 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
3054 },
3055 ),
3056 (
3057 "fields/ref-picker.css",
3058 AdminAsset {
3059 mime: "text/css; charset=utf-8",
3060 bytes: std::borrow::Cow::Borrowed(include_bytes!(
3061 "../assets/fields/ref-picker.css"
3062 )),
3063 },
3064 ),
3065 (
3066 "mark.svg",
3067 AdminAsset {
3068 mime: "image/svg+xml",
3069 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
3070 },
3071 ),
3072 (
3073 "mark.png",
3074 AdminAsset {
3075 mime: "image/png",
3076 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
3077 },
3078 ),
3079 (
3080 "fonts/space-grotesk-500.woff2",
3081 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
3082 ),
3083 (
3084 "fonts/space-grotesk-600.woff2",
3085 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
3086 ),
3087 (
3088 "fonts/space-grotesk-700.woff2",
3089 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
3090 ),
3091 (
3092 "fonts/ibm-plex-sans-400.woff2",
3093 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
3094 ),
3095 (
3096 "fonts/ibm-plex-sans-600.woff2",
3097 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
3098 ),
3099 (
3100 "fonts/ibm-plex-mono-400.woff2",
3101 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
3102 ),
3103 (
3104 "fonts/ibm-plex-mono-600.woff2",
3105 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
3106 ),
3107 ])
3108}
3109
3110async fn serve_asset(
3112 State(state): State<AdminState>,
3113 headers: HeaderMap,
3114 Path(path): Path<String>,
3115) -> Response {
3116 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
3120 let Some(asset) = state.assets.get(key.as_str()) else {
3121 return not_found();
3122 };
3123 let policy = if fingerprinted {
3124 http_cache::IMMUTABLE
3125 } else {
3126 http_cache::REVALIDATE
3127 };
3128 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
3129}
3130
3131fn mount_resource(
3136 resource: &Resource,
3137 field_types: &field::FieldRegistry,
3138 column_types: &list::ColumnRegistry,
3139 permission_codes: &std::collections::HashSet<String>,
3140 persisters: &persist::PersisterRegistry,
3141 listeners: &[laterite_core::ModelListenerReg],
3142) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
3143 list::check(&resource.list, column_types, permission_codes)
3147 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3148 if resource.form.is_some() && resource.permission.is_none() {
3153 panic!(
3154 "admin resource `{}` has a create/edit form but no permission; a write \
3155 resource must declare a permission",
3156 resource.base_path
3157 );
3158 }
3159 let base = resource.base_path.clone();
3160 let list_path = base.clone();
3161 let mut list_cfg = resource.list.clone();
3162 if resource.list.columns.iter().any(|c| c.editable) {
3165 let Some(form_cfg) = resource.form.clone() else {
3166 panic!(
3167 "admin resource `{}` has an editable column but no form to edit through",
3168 resource.base_path
3169 );
3170 };
3171 for column in resource.list.columns.iter().filter(|c| c.editable) {
3172 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
3173 panic!(
3174 "admin resource `{}`: column `{}` is editable but the form has no such field",
3175 resource.base_path, column.field
3176 );
3177 }
3178 }
3179 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
3180 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3181 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
3182 }
3183 let cell_cfg = list_cfg.clone();
3184 let mut router = Router::new().route(
3185 &base,
3186 get(
3187 move |State(state): State<AdminState>,
3188 Extension(shell): Extension<Shell>,
3189 Query(params): Query<list::ListParams>,
3190 Query(raw): Query<std::collections::HashMap<String, String>>,
3191 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
3192 Extension(user): Extension<AuthenticatedUser>,
3193 Extension(session): Extension<session::SessionHandle>,
3194 headers: axum::http::HeaderMap| {
3195 let cfg = list_cfg.clone();
3196 let path = list_path.clone();
3197 async move {
3198 list::handle_remembering(
3199 &state,
3200 &cfg,
3201 &path,
3202 params,
3203 &raw,
3204 &user,
3205 shell,
3206 &headers,
3207 query_string.as_deref(),
3208 Some(&session),
3209 )
3210 .await
3211 }
3212 },
3213 ),
3214 );
3215
3216 if cell_cfg.editor.is_some() {
3217 let (cfg, list_path) = (cell_cfg, base.clone());
3218 router = router.route(
3219 &format!("{base}/{{id}}/cell"),
3220 post(
3221 move |State(state): State<AdminState>,
3222 Extension(shell): Extension<Shell>,
3223 Extension(user): Extension<AuthenticatedUser>,
3224 Extension(session): Extension<session::SessionHandle>,
3225 Path(id): Path<String>,
3226 headers: axum::http::HeaderMap,
3227 Form(data): Form<HashMap<String, String>>| {
3228 let (cfg, path) = (cfg.clone(), list_path.clone());
3229 async move {
3230 list::cell_handler(
3231 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
3232 )
3233 .await
3234 }
3235 },
3236 ),
3237 );
3238 }
3239
3240 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
3243 if resource.list.exportable {
3244 router = router.route(
3245 &format!("{base}/export"),
3246 get(
3247 move |state: State<AdminState>,
3248 Query(raw): Query<std::collections::HashMap<String, String>>,
3249 Extension(user): Extension<AuthenticatedUser>,
3250 Extension(shell): Extension<Shell>,
3251 Extension(session): Extension<session::SessionHandle>| {
3252 let cfg = export_cfg.clone();
3253 let path = export_path.clone();
3254 async move {
3255 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
3256 }
3257 },
3258 ),
3259 );
3260 }
3261
3262 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
3263 router = router.route(
3264 &format!("{base}/columns"),
3265 post(
3266 move |State(state): State<AdminState>,
3267 Extension(user): Extension<AuthenticatedUser>,
3268 Extension(session): Extension<session::SessionHandle>,
3269 headers: axum::http::HeaderMap,
3270 Form(pairs): Form<Vec<(String, String)>>| {
3271 let cfg = columns_cfg.clone();
3272 let path = columns_path.clone();
3273 async move {
3274 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
3275 }
3276 },
3277 ),
3278 );
3279
3280 if resource.list.deletable {
3281 let persister: Arc<dyn persist::Persister> =
3285 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
3286 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
3287 panic!(
3288 "admin resource `{}`: unregistered persister `{key}`",
3289 resource.base_path
3290 )
3291 }),
3292 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
3293 };
3294 let ctx = Arc::new(bulk::BulkContext {
3295 base_path: resource.base_path.clone(),
3296 entity: resource.list.entity.clone(),
3297 persister,
3298 listeners: listeners
3299 .iter()
3300 .filter(|reg| reg.matches(&resource.list.entity))
3301 .map(|reg| reg.listener.clone())
3302 .collect(),
3303 });
3304 router =
3305 router.route(
3306 &format!("{base}/delete"),
3307 post(
3308 move |state: State<AdminState>,
3309 user: Extension<AuthenticatedUser>,
3310 session: Extension<session::SessionHandle>,
3311 headers: axum::http::HeaderMap,
3312 form: Form<Vec<(String, String)>>| {
3313 let ctx = ctx.clone();
3314 async move {
3315 bulk::delete_handler(state, user, session, ctx, headers, form).await
3316 }
3317 },
3318 ),
3319 );
3320 }
3321
3322 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
3325 let tree = resource.list.tree.clone().unwrap_or_default();
3326 let mut move_cfg = form::FormConfig::new(
3327 resource.list.entity.clone(),
3328 laterite_core::t!("Move to"),
3329 base.clone(),
3330 resource.list.id_field.clone(),
3331 vec![form::FormField::reference(
3332 &tree.parent_key,
3333 laterite_core::t!("New parent"),
3334 &source,
3335 )
3336 .help(laterite_core::t!("Leave empty for the top level."))],
3337 );
3338 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
3339 move_cfg = move_cfg.persist(persist);
3340 }
3341 move_cfg.done_path = Some(base.clone());
3342 let move_form = Arc::new(
3343 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
3344 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
3345 );
3346 let (cfg, list_path) = (resource.list.clone(), base.clone());
3347 let (form_get, form_post) = (move_form.clone(), move_form);
3348 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
3349 router = router.route(
3350 &format!("{base}/{{id}}/move"),
3351 get(
3352 move |State(state): State<AdminState>,
3353 Extension(shell): Extension<Shell>,
3354 Path(id): Path<String>| {
3355 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
3356 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
3357 },
3358 )
3359 .post(
3360 move |State(state): State<AdminState>,
3361 Extension(shell): Extension<Shell>,
3362 Extension(user): Extension<AuthenticatedUser>,
3363 Extension(session): Extension<session::SessionHandle>,
3364 Path(id): Path<String>,
3365 headers: axum::http::HeaderMap,
3366 Form(data): Form<HashMap<String, String>>| {
3367 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
3368 async move {
3369 list::move_record(
3370 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
3371 )
3372 .await
3373 }
3374 },
3375 ),
3376 );
3377 }
3378
3379 if resource.list.reorder.is_some() {
3380 let (cfg, list_path) = (resource.list.clone(), base.clone());
3381 router = router.route(
3382 &format!("{base}/reorder"),
3383 post(
3384 move |State(state): State<AdminState>,
3385 Extension(shell): Extension<Shell>,
3386 Extension(user): Extension<AuthenticatedUser>,
3387 Extension(session): Extension<session::SessionHandle>,
3388 headers: axum::http::HeaderMap,
3389 Form(pairs): Form<Vec<(String, String)>>| {
3390 let cfg = cfg.clone();
3391 let path = list_path.clone();
3392 async move {
3393 list::reorder_handler(
3394 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
3395 &headers, &pairs,
3396 )
3397 .await
3398 }
3399 },
3400 ),
3401 );
3402 }
3403
3404 if !resource.relations.is_empty() && resource.form.is_none() {
3407 panic!(
3408 "admin resource `{}` declares relations but has no form to show them on",
3409 resource.base_path
3410 );
3411 }
3412 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
3413 for rel in &resource.relations {
3414 let prepared = relation::PreparedRelation::prepare(
3415 resource,
3416 rel.clone(),
3417 field_types,
3418 column_types,
3419 permission_codes,
3420 persisters,
3421 listeners,
3422 )
3423 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3424 relations.push(Arc::new(prepared));
3425 }
3426 let relations = Arc::new(relations);
3427
3428 let mut prepared_form = None;
3429 if let Some(form_cfg) = resource.form.clone() {
3430 let mut prepared =
3433 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
3434 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3435 prepared.stay = !relations.is_empty();
3437 let prepared = Arc::new(prepared);
3438 prepared_form = Some(prepared.clone());
3439 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
3440 let relations_for_new = relations.clone();
3441 router = router.route(
3442 &format!("{base}/new"),
3443 get(
3444 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
3445 let pf = new_pf.clone();
3446 let relations = relations_for_new.clone();
3447 async move {
3448 let pending = relations
3449 .iter()
3450 .map(|r| shell.tt(&r.config.title))
3451 .collect();
3452 form::new_form(&state, &pf, shell, pending)
3453 }
3454 },
3455 )
3456 .post(
3457 move |State(state): State<AdminState>,
3458 Extension(shell): Extension<Shell>,
3459 Extension(user): Extension<AuthenticatedUser>,
3460 Extension(session): Extension<session::SessionHandle>,
3461 headers: axum::http::HeaderMap,
3462 Form(data): Form<HashMap<String, String>>| {
3463 let pf = create_pf.clone();
3464 async move {
3465 form::create(&state, &pf, data, shell, &user, &session, &headers).await
3466 }
3467 },
3468 ),
3469 );
3470
3471 let reveal_pf = prepared.clone();
3472 router = router.route(
3473 &format!("{base}/{{id}}/reveal/{{field}}"),
3474 get(
3475 move |State(state): State<AdminState>,
3476 Extension(user): Extension<AuthenticatedUser>,
3477 Path((id, field)): Path<(String, String)>| {
3478 let pf = reveal_pf.clone();
3479 async move { form::reveal(&state, &pf, &id, &field, &user).await }
3480 },
3481 ),
3482 );
3483
3484 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
3485 let relations_for_edit = relations.clone();
3486 router = router.route(
3487 &format!("{base}/{{id}}/edit"),
3488 get(
3489 move |State(state): State<AdminState>,
3490 Extension(shell): Extension<Shell>,
3491 Extension(user): Extension<AuthenticatedUser>,
3492 Path(id): Path<String>| {
3493 let pf = edit_pf.clone();
3494 let relations = relations_for_edit.clone();
3495 async move {
3496 let mut sections = Vec::new();
3497 for rel in relations.iter() {
3498 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
3499 sections.push(section);
3500 }
3501 }
3502 form::edit_form(&state, &pf, id, shell, sections).await
3503 }
3504 },
3505 )
3506 .post(
3507 move |State(state): State<AdminState>,
3508 Extension(shell): Extension<Shell>,
3509 Extension(user): Extension<AuthenticatedUser>,
3510 Extension(session): Extension<session::SessionHandle>,
3511 Path(id): Path<String>,
3512 headers: axum::http::HeaderMap,
3513 Form(data): Form<HashMap<String, String>>| {
3514 let pf = update_pf.clone();
3515 async move {
3516 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
3517 }
3518 },
3519 ),
3520 );
3521 }
3522
3523 for rel in relations.iter() {
3524 let name = rel.config.name.clone();
3525 let mut routes: Router<AdminState> = Router::new();
3526 let r = rel.clone();
3527 routes =
3528 routes.route(
3529 &format!("{base}/{{id}}/{name}"),
3530 get(
3531 move |state: State<AdminState>,
3532 shell: Extension<Shell>,
3533 user: Extension<AuthenticatedUser>,
3534 id: Path<String>,
3535 params: Query<list::ListParams>,
3536 raw: Query<HashMap<String, String>>,
3537 headers: axum::http::HeaderMap| {
3538 let r = r.clone();
3539 async move {
3540 relation::index(state, shell, user, id, params, raw, headers, r).await
3541 }
3542 },
3543 ),
3544 );
3545 let (r_new, r_create) = (rel.clone(), rel.clone());
3546 routes = routes.route(
3547 &format!("{base}/{{id}}/{name}/new"),
3548 get(
3549 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
3550 let r = r_new.clone();
3551 async move { relation::new_form(state, shell, id, r).await }
3552 },
3553 )
3554 .post(
3555 move |state: State<AdminState>,
3556 shell: Extension<Shell>,
3557 user: Extension<AuthenticatedUser>,
3558 session: Extension<session::SessionHandle>,
3559 id: Path<String>,
3560 headers: axum::http::HeaderMap,
3561 data: Form<HashMap<String, String>>| {
3562 let r = r_create.clone();
3563 async move {
3564 relation::create(state, shell, user, session, id, headers, data, r).await
3565 }
3566 },
3567 ),
3568 );
3569 let (r_edit, r_update) = (rel.clone(), rel.clone());
3570 routes = routes.route(
3571 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
3572 get(
3573 move |state: State<AdminState>,
3574 shell: Extension<Shell>,
3575 ids: Path<(String, String)>| {
3576 let r = r_edit.clone();
3577 async move { relation::edit_form(state, shell, ids, r).await }
3578 },
3579 )
3580 .post(
3581 move |state: State<AdminState>,
3582 shell: Extension<Shell>,
3583 user: Extension<AuthenticatedUser>,
3584 session: Extension<session::SessionHandle>,
3585 ids: Path<(String, String)>,
3586 headers: axum::http::HeaderMap,
3587 data: Form<HashMap<String, String>>| {
3588 let r = r_update.clone();
3589 async move {
3590 relation::update(state, shell, user, session, ids, headers, data, r).await
3591 }
3592 },
3593 ),
3594 );
3595 let r_delete = rel.clone();
3596 routes = routes.route(
3597 &format!("{base}/{{id}}/{name}/delete"),
3598 post(
3599 move |state: State<AdminState>,
3600 shell: Extension<Shell>,
3601 user: Extension<AuthenticatedUser>,
3602 session: Extension<session::SessionHandle>,
3603 id: Path<String>,
3604 headers: axum::http::HeaderMap,
3605 pairs: Form<Vec<(String, String)>>| {
3606 let r = r_delete.clone();
3607 async move {
3608 relation::delete(state, shell, user, session, id, headers, pairs, r).await
3609 }
3610 },
3611 ),
3612 );
3613 if rel.reorderable() {
3614 let r_reorder = rel.clone();
3615 routes = routes.route(
3616 &format!("{base}/{{id}}/{name}/reorder"),
3617 post(
3618 move |state: State<AdminState>,
3619 shell: Extension<Shell>,
3620 user: Extension<AuthenticatedUser>,
3621 session: Extension<session::SessionHandle>,
3622 id: Path<String>,
3623 headers: axum::http::HeaderMap,
3624 pairs: Form<Vec<(String, String)>>| {
3625 let r = r_reorder.clone();
3626 async move {
3627 relation::reorder(state, shell, user, session, id, headers, pairs, r)
3628 .await
3629 }
3630 },
3631 ),
3632 );
3633 }
3634 if let Some(permission) = &rel.config.permission {
3635 routes = guard_with_permission(routes, permission);
3636 }
3637 router = router.merge(routes);
3638 }
3639
3640 if let Some(permission) = &resource.permission {
3642 router = guard_with_permission(router, permission);
3643 }
3644 (router, prepared_form)
3645}
3646
3647fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
3652 let needed: Arc<str> = Arc::from(permission);
3653 router.route_layer(middleware::from_fn(
3654 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
3655 let needed = needed.clone();
3656 async move {
3657 if user.allows(&needed) {
3658 next.run(req).await
3659 } else {
3660 forbidden()
3661 }
3662 }
3663 },
3664 ))
3665}
3666
3667async fn require_auth(
3673 State(state): State<AdminState>,
3674 jar: CookieJar,
3675 request: Request,
3676 next: Next,
3677) -> Response {
3678 let login = format!("{}/login", state.admin_path);
3679 let client_ctx = request
3680 .extensions()
3681 .get::<RequestContext>()
3682 .cloned()
3683 .unwrap_or_default();
3684 let mut ended: Option<laterite_auth::SessionEnd> = None;
3689 let live = match jar.get(SESSION_COOKIE) {
3690 Some(cookie) => {
3691 let token = cookie.value().to_string();
3692 match state.auth.resolve_session(&token).await {
3693 Ok(resolved) => Some((token, resolved)),
3694 Err(_) => {
3695 ended = state.auth.session_end(&token).await.ok().flatten();
3696 None
3697 }
3698 }
3699 }
3700 None => None,
3701 };
3702 let htmx = request.headers().contains_key("hx-request");
3703 let (token, resolved, recalled) = match live {
3704 Some((token, resolved)) => (token, resolved, None),
3705 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
3708 return signed_out(&state, jar, &login, ended, htmx);
3709 }
3710 None => match recall(&state, &jar, &client_ctx).await {
3711 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
3712 None => {
3713 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3717 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
3718 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
3719 }
3720 }
3721 return signed_out(&state, jar, &login, ended, htmx);
3722 }
3723 },
3724 };
3725 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
3726
3727 let mut request = request;
3732 let safe = session::is_safe_method(request.method());
3733 let mut deferred: Option<session::CsrfPending> = None;
3735 if !safe {
3736 let (parts, body) = request.into_parts();
3737 let (body, submitted) = if session::is_multipart(&parts.headers) {
3744 let token = session::header_token(&parts.headers)
3745 .or_else(|| session::query_token(parts.uri.query()));
3746 if token.is_none() {
3751 deferred = Some(session::CsrfPending::default());
3752 }
3753 (body, token)
3754 } else {
3755 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
3756 .await
3757 .unwrap_or_default();
3758 let token = session::submitted_token(&parts.headers, &bytes);
3759 (Body::from(bytes), token)
3760 };
3761 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
3762 {
3763 tracing::warn!(
3764 user_id = resolved.identity.user.id,
3765 "admin CSRF check failed"
3766 );
3767 return error::csrf_rejected();
3768 }
3769 request = Request::from_parts(parts, body);
3770 }
3771
3772 let user = resolved.identity;
3773 let acting_user_id = user.user.id;
3775 let path = request.uri().path().to_string();
3776 let preferences = format!("{}/preferences", state.admin_path);
3779 if !path.starts_with(&preferences)
3780 && path != format!("{}/logout", state.admin_path)
3781 && state
3782 .auth
3783 .must_change_password(user.user.id)
3784 .await
3785 .unwrap_or(false)
3786 {
3787 let to = format!("{preferences}#password");
3788 let response = if htmx {
3789 (
3790 [("HX-Redirect", to.as_str())],
3791 axum::http::StatusCode::NO_CONTENT,
3792 )
3793 .into_response()
3794 } else {
3795 Redirect::to(&to).into_response()
3796 };
3797 return with_recall_cookies(response, recalled, jar, token, &state);
3798 }
3799 let flash = if safe {
3802 handle.take_flash()
3803 } else {
3804 Vec::new()
3805 };
3806 let accept_language = request
3810 .headers()
3811 .get("accept-language")
3812 .and_then(|v| v.to_str().ok());
3813 let serveable = state.catalogs.locales();
3814 let chain = resolve_locale_chain(
3815 user.user.locale.as_deref(),
3816 accept_language,
3817 &state.default_locale,
3818 &serveable,
3819 );
3820 let i18n = Translator::with_chain(chain, state.catalogs.clone());
3821 let translator = i18n.clone();
3824 let own = OwnMenu::load(&state, user.user.id).await;
3826 let (own_nav, own_settings) = own_tree(&state, &own);
3827 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
3828 &own_nav,
3829 &own_settings,
3830 &state.admin_path,
3831 &user.permissions,
3832 own.nav_order.as_deref(),
3833 &path,
3834 &i18n,
3835 icons::Icons::new(&state.icons, &state.sprite_url),
3836 &state.order,
3837 own.settings_order.as_deref(),
3838 );
3839 let brand = state.brand().await;
3840 let shell = Shell::new(
3841 &state.admin_path,
3842 brand,
3843 state.skin.clone(),
3844 &visible_nav,
3845 &user,
3846 state.timezone,
3847 sidebar,
3848 active_nav.as_deref(),
3849 handle.csrf_token(),
3850 flash,
3851 i18n,
3852 state.asset_urls.clone(),
3853 state.origin.to_string(),
3854 state.icons.clone(),
3855 state.sprite_url.clone(),
3856 );
3857 request.extensions_mut().insert(user);
3858 request.extensions_mut().insert(shell);
3859 request.extensions_mut().insert(handle.clone());
3860 if let Some(pending) = &deferred {
3862 request.extensions_mut().insert(pending.clone());
3863 }
3864 let mut response = next.run(request).await;
3865
3866 if let Some(pending) = &deferred {
3871 if !pending.was_satisfied() {
3872 tracing::error!(
3873 user_id = acting_user_id,
3874 "a multipart route returned without verifying its request token"
3875 );
3876 return error::csrf_rejected();
3877 }
3878 }
3879
3880 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
3884 response = error::localized_error(meta.kind, &translator);
3885 }
3886
3887 if let Some(blob) = handle.dirty_blob() {
3890 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
3891 tracing::error!(error = %e, "persisting admin session failed");
3892 }
3893 }
3894 with_recall_cookies(response, recalled, jar, token, &state)
3895}
3896
3897fn with_recall_cookies(
3901 response: Response,
3902 recalled: Option<laterite_auth::RememberCredential>,
3903 jar: CookieJar,
3904 token: String,
3905 state: &AdminState,
3906) -> Response {
3907 match recalled {
3908 Some(credential) => {
3909 let jar = jar
3910 .add(session_cookie(
3911 token,
3912 &state.cookie_path,
3913 state.secure_cookie,
3914 ))
3915 .add(remember_cookie(
3916 credential,
3917 &state.cookie_path,
3918 state.secure_cookie,
3919 ));
3920 (jar, response).into_response()
3921 }
3922 None => response,
3923 }
3924}
3925
3926async fn recall(
3930 state: &AdminState,
3931 jar: &CookieJar,
3932 ctx: &RequestContext,
3933) -> Option<(
3934 String,
3935 laterite_auth::ResolvedSession,
3936 laterite_auth::RememberCredential,
3937)> {
3938 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3939 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3940 let token = recalled.session.token.clone();
3941 let resolved = state.auth.resolve_session(&token).await.ok()?;
3942 Some((token, resolved, recalled.remember))
3943}
3944
3945const MAX_FORM_BYTES: usize = 1024 * 1024;
3948
3949async fn capture_client(
3957 State(state): State<AdminState>,
3958 mut request: Request,
3959 next: Next,
3960) -> Response {
3961 let peer = request
3962 .extensions()
3963 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3964 .map(|info| info.0);
3965 let headers = request.headers();
3966 let ctx = RequestContext {
3967 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3968 user_agent: clientip::user_agent(headers),
3969 };
3970 request.extensions_mut().insert(ctx);
3971 next.run(request).await
3972}
3973
3974async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3975 if !session::is_safe_method(request.method())
3976 && !session::origin_ok(request.headers(), &state.origin)
3977 {
3978 tracing::warn!(
3979 path = request.uri().path(),
3980 "admin CSRF origin check failed"
3981 );
3982 return error::csrf_rejected();
3983 }
3984 next.run(request).await
3985}
3986
3987#[derive(Deserialize)]
3988struct LoginQuery {
3989 ended: Option<String>,
3990}
3991
3992async fn login_form(
3993 State(state): State<AdminState>,
3994 headers: HeaderMap,
3995 Query(query): Query<LoginQuery>,
3996) -> Response {
3997 match state.auth.has_any_operator().await {
3999 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
4000 Ok(true) => {
4001 let i18n = pre_auth_translator(&state, &headers);
4002 let notice = query
4003 .ended
4004 .as_deref()
4005 .and_then(end_notice)
4006 .map(|text| i18n.t(&text));
4007 render(LoginTemplate {
4008 base: state.admin_path.to_string(),
4009 asset_urls: state.asset_urls.clone(),
4010 brand: state.brand().await.name.clone(),
4011 look: state.brand().await,
4012 skin: state.skin.clone(),
4013 error: None,
4014 notice,
4015 i18n,
4016 })
4017 }
4018 Err(_) => render_error(),
4019 }
4020}
4021
4022#[derive(Deserialize)]
4023struct LoginForm {
4024 username: String,
4025 password: String,
4026 #[serde(default)]
4028 remember: Option<String>,
4029}
4030
4031async fn login_submit(
4032 State(state): State<AdminState>,
4033 jar: CookieJar,
4034 headers: HeaderMap,
4035 Extension(ctx): Extension<RequestContext>,
4036 Form(form): Form<LoginForm>,
4037) -> Response {
4038 match state
4039 .auth
4040 .authenticate(&form.username, &form.password, &ctx)
4041 .await
4042 {
4043 Ok(session) => {
4044 let wants_remember = form.remember.is_some();
4045 let user_id = session.user_id;
4046 let mut jar = jar.add(session_cookie(
4047 session.token,
4048 &state.cookie_path,
4049 state.secure_cookie,
4050 ));
4051 if wants_remember {
4052 match state.auth.issue_remember(user_id).await {
4053 Ok(credential) => {
4054 jar = jar.add(remember_cookie(
4055 credential,
4056 &state.cookie_path,
4057 state.secure_cookie,
4058 ));
4059 }
4060 Err(e) => {
4062 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
4063 }
4064 }
4065 }
4066 (jar, Redirect::to(&state.admin_path)).into_response()
4067 }
4068 Err(_) => {
4069 let i18n = pre_auth_translator(&state, &headers);
4070 render(LoginTemplate {
4071 base: state.admin_path.to_string(),
4072 asset_urls: state.asset_urls.clone(),
4073 brand: state.brand().await.name.clone(),
4074 look: state.brand().await,
4075 skin: state.skin.clone(),
4076 error: Some(i18n.t(&t!("Invalid username or password."))),
4077 notice: None,
4078 i18n,
4079 })
4080 }
4081 }
4082}
4083
4084fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
4094 Cookie::build((SESSION_COOKIE, token))
4095 .path(admin_path.to_string())
4096 .http_only(true)
4097 .secure(secure)
4098 .same_site(SameSite::Lax)
4099 .build()
4100}
4101
4102fn remember_cookie(
4105 credential: laterite_auth::RememberCredential,
4106 admin_path: &str,
4107 secure: bool,
4108) -> Cookie<'static> {
4109 let seconds = (credential.expires_at - chrono::Utc::now())
4110 .num_seconds()
4111 .max(0);
4112 Cookie::build((REMEMBER_COOKIE, credential.cookie))
4113 .path(admin_path.to_string())
4114 .http_only(true)
4115 .secure(secure)
4116 .same_site(SameSite::Lax)
4117 .max_age(cookie::time::Duration::seconds(seconds))
4118 .build()
4119}
4120
4121fn signed_out(
4128 state: &AdminState,
4129 jar: CookieJar,
4130 login: &str,
4131 ended: Option<laterite_auth::SessionEnd>,
4132 htmx: bool,
4133) -> Response {
4134 let to = match ended.map(laterite_auth::SessionEnd::code) {
4135 Some(code) => format!("{login}?ended={code}"),
4136 None => login.to_string(),
4137 };
4138 let jar = jar
4139 .remove(
4140 Cookie::build((SESSION_COOKIE, ""))
4141 .path(state.cookie_path.to_string())
4142 .build(),
4143 )
4144 .remove(remember_removal(&state.cookie_path));
4145 if htmx {
4146 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
4147 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
4148 response.headers_mut().insert("hx-redirect", value);
4149 }
4150 return response;
4151 }
4152 (jar, Redirect::to(&to)).into_response()
4153}
4154
4155fn end_notice(code: &str) -> Option<Text> {
4158 Some(match code {
4159 "password_changed" => {
4160 t!("Your password was changed, so you were signed out. Sign in with the new one.")
4161 }
4162 "deactivated" => t!("This account was deactivated."),
4163 "signed_out_elsewhere" => t!("You were signed out from another device."),
4164 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
4165 "password_set" => t!("Your password is set. Sign in with it."),
4166 _ => return None,
4167 })
4168}
4169
4170fn remember_removal(admin_path: &str) -> Cookie<'static> {
4172 Cookie::build((REMEMBER_COOKIE, ""))
4173 .path(admin_path.to_string())
4174 .build()
4175}
4176
4177#[derive(Deserialize)]
4178struct SetupForm {
4179 username: String,
4180 first_name: String,
4181 last_name: String,
4182 email: String,
4183 password: String,
4184 timezone: String,
4185}
4186
4187async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
4190 match state.auth.has_any_operator().await {
4191 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4192 Ok(false) => render(setup_view(
4193 &state.admin_path,
4194 state.brand().await,
4195 state.skin.clone(),
4196 state.timezone,
4197 None,
4198 pre_auth_translator(&state, &headers),
4199 state.asset_urls.clone(),
4200 state.auth.password_policy().min_length,
4201 )),
4202 Err(_) => render_error(),
4203 }
4204}
4205
4206async fn setup_submit(
4207 State(state): State<AdminState>,
4208 jar: CookieJar,
4209 headers: HeaderMap,
4210 client: Option<Extension<RequestContext>>,
4211 Form(form): Form<SetupForm>,
4212) -> Response {
4213 match state.auth.has_any_operator().await {
4215 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4216 Ok(false) => {}
4217 Err(_) => return render_error(),
4218 }
4219
4220 let username = form.username.trim();
4221 let email = form.email.trim();
4222 let first_name = form.first_name.trim();
4223 let last_name = form.last_name.trim();
4224 let tz = form.timezone.trim();
4225 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
4226 {
4227 return render(setup_view(
4228 &state.admin_path,
4229 state.brand().await,
4230 state.skin.clone(),
4231 state.timezone,
4232 Some(t!(
4233 "Username, first name, email, and password are all required."
4234 )),
4235 pre_auth_translator(&state, &headers),
4236 state.asset_urls.clone(),
4237 state.auth.password_policy().min_length,
4238 ));
4239 }
4240 if tz.parse::<Tz>().is_err() {
4242 return render(setup_view(
4243 &state.admin_path,
4244 state.brand().await,
4245 state.skin.clone(),
4246 state.timezone,
4247 Some(t!("That is not a recognised timezone.")),
4248 pre_auth_translator(&state, &headers),
4249 state.asset_urls.clone(),
4250 state.auth.password_policy().min_length,
4251 ));
4252 }
4253
4254 let min_length = state.auth.password_policy().min_length;
4255 if form.password.chars().count() < min_length {
4256 return render(setup_view(
4257 &state.admin_path,
4258 state.brand().await,
4259 state.skin.clone(),
4260 state.timezone,
4261 Some(t!(
4262 "A password needs at least {n} characters.",
4263 n = min_length as i64
4264 )),
4265 pre_auth_translator(&state, &headers),
4266 state.asset_urls.clone(),
4267 state.auth.password_policy().min_length,
4268 ));
4269 }
4270 let new = NewOperator {
4271 username,
4272 email,
4273 first_name,
4274 last_name: (!last_name.is_empty()).then_some(last_name),
4275 password: &form.password,
4276 timezone: Some(tz),
4277 };
4278 let id = match state.auth.create_superuser(new).await {
4279 Ok(id) => id,
4280 Err(_) => {
4281 return render(setup_view(
4282 &state.admin_path,
4283 state.brand().await,
4284 state.skin.clone(),
4285 state.timezone,
4286 Some(t!(
4287 "Could not create the account. The username or email may already be taken."
4288 )),
4289 pre_auth_translator(&state, &headers),
4290 state.asset_urls.clone(),
4291 state.auth.password_policy().min_length,
4292 ));
4293 }
4294 };
4295 let actor = laterite_core::Actor::system("first-run setup");
4298 let target = id.to_string();
4299 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
4300 "backend_user",
4301 &target,
4302 Some(username),
4303 );
4304 if let Err(e) = state.auth.record_audit(entry).await {
4305 tracing::error!(error = %e, "failed to write audit log entry");
4306 }
4307 let completed = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4308 if let Err(e) = state.parameters.set(SETUP_COMPLETED_AT, &completed).await {
4309 tracing::warn!(error = %e, "failed to record the setup completion");
4310 }
4311
4312 match state
4314 .auth
4315 .authenticate(
4316 username,
4317 &form.password,
4318 &client.map(|c| c.0).unwrap_or_default(),
4319 )
4320 .await
4321 {
4322 Ok(session) => {
4323 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
4324 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
4325 }
4326 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4327 }
4328}
4329
4330#[allow(clippy::too_many_arguments)]
4333fn setup_view(
4334 admin_path: &str,
4335 brand: Arc<settings::brand::Brand>,
4336 skin: Arc<skin::Active>,
4337 default_tz: Tz,
4338 error: Option<Text>,
4339 i18n: Translator,
4340 asset_urls: Arc<AssetUrls>,
4341 min_password: usize,
4342) -> SetupTemplate {
4343 let default_name = default_tz.name();
4344 let zones = TZ_VARIANTS
4345 .iter()
4346 .filter(|tz| zone_offered(tz.name()))
4347 .map(|tz| TzOption {
4348 name: tz.name().to_string(),
4349 selected: tz.name() == default_name,
4350 })
4351 .collect();
4352 SetupTemplate {
4353 base: admin_path.to_string(),
4354 asset_urls,
4355 brand: brand.name.clone(),
4356 look: brand,
4357 skin,
4358 zones,
4359 error: error.map(|e| i18n.t(&e)),
4360 min_password: min_password as i64,
4361 i18n,
4362 }
4363}
4364
4365async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
4366 if let Some(cookie) = jar.get(SESSION_COOKIE) {
4367 let _ = state.auth.logout(cookie.value()).await;
4368 }
4369 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
4372 let _ = state.auth.revoke_remember(cookie.value()).await;
4373 }
4374 let removal = Cookie::build((SESSION_COOKIE, ""))
4375 .path(state.cookie_path.to_string())
4376 .build();
4377 (
4378 jar.remove(removal)
4379 .remove(remember_removal(&state.cookie_path)),
4380 Redirect::to(&format!("{}/login", state.admin_path)),
4381 )
4382 .into_response()
4383}
4384
4385async fn dashboard(
4386 State(state): State<AdminState>,
4387 Extension(shell): Extension<Shell>,
4388 Extension(user): Extension<AuthenticatedUser>,
4389) -> Response {
4390 let layout = laterite_auth::store::user_preference(&state.db, user.user.id, widget::PREFERENCE)
4391 .await
4392 .ok()
4393 .flatten()
4394 .and_then(|json| widget::Layout::parse(&json));
4395 let deployment = state.order.get(widget::HIERARCHY).map(Vec::as_slice);
4396 let allows = |p: &str| user.permissions.allows(p);
4397 let available = widget::available(&state.widgets, deployment, &allows);
4398 let shown = widget::resolve(layout.as_ref(), &available);
4399 let failed = shell.tt(&t!("This widget could not be shown."));
4400 let mut widgets = Vec::with_capacity(shown.len());
4401 for (reg, size) in &shown {
4402 let cx = widget::WidgetCx {
4403 state: &state,
4404 shell: &shell,
4405 user: &user,
4406 size: *size,
4407 };
4408 let body = reg.widget.render(&cx).await;
4409 widgets.push(widget::frame(
4410 reg,
4411 *size,
4412 &shell.base,
4413 &shell.tt(®.widget.label()),
4414 body,
4415 &failed,
4416 ));
4417 }
4418 let available = available
4419 .iter()
4420 .map(|reg| {
4421 let placed = shown.iter().find(|(r, _)| r.code() == reg.code());
4422 AvailableWidgetView {
4423 code: reg.code().to_string(),
4424 label: shell.tt(®.widget.label()),
4425 description: reg
4426 .widget
4427 .description()
4428 .map(|d| shell.tt(&d))
4429 .unwrap_or_default(),
4430 shown: placed.is_some(),
4431 size: placed
4432 .map(|(_, s)| *s)
4433 .unwrap_or_else(|| reg.widget.size())
4434 .key()
4435 .to_string(),
4436 }
4437 })
4438 .collect();
4439 render(DashboardTemplate {
4440 shell,
4441 widgets,
4442 available,
4443 })
4444}
4445
4446async fn dashboard_widget(
4448 State(state): State<AdminState>,
4449 Extension(shell): Extension<Shell>,
4450 Extension(user): Extension<AuthenticatedUser>,
4451 Path(code): Path<String>,
4452 Query(query): Query<HashMap<String, String>>,
4453) -> Response {
4454 let Some(reg) = state.widgets.iter().find(|w| w.code() == code) else {
4455 return AdminError::NotFound.into_response();
4456 };
4457 if let Some(p) = reg.widget.permission() {
4458 if !user.permissions.allows(p) {
4459 return AdminError::Forbidden.into_response();
4460 }
4461 }
4462 let size = query
4463 .get("size")
4464 .and_then(|s| widget::Size::parse(s))
4465 .unwrap_or_else(|| reg.widget.size());
4466 let cx = widget::WidgetCx {
4467 state: &state,
4468 shell: &shell,
4469 user: &user,
4470 size,
4471 };
4472 match reg.widget.render(&cx).await {
4473 Ok(markup) => Html(markup.into_string()).into_response(),
4474 Err(e) => {
4475 tracing::warn!(widget = reg.code(), error = %e, "widget failed to render");
4476 Html(format!(
4477 r#"<p class="lat-widget__failed">{}</p>"#,
4478 form::escape_attr(&shell.tt(&t!("This widget could not be shown.")))
4479 ))
4480 .into_response()
4481 }
4482 }
4483}
4484
4485async fn dashboard_layout_update(
4488 State(state): State<AdminState>,
4489 Extension(user): Extension<AuthenticatedUser>,
4490 Extension(session): Extension<session::SessionHandle>,
4491 body: String,
4492) -> Response {
4493 let mut layout = widget::Layout::default();
4494 let mut sizes: HashMap<String, widget::Size> = HashMap::new();
4495 let mut order: Vec<String> = Vec::new();
4496 for (key, value) in form_urlencoded::parse(body.as_bytes()) {
4497 if key == "widget" {
4498 if state.widgets.iter().any(|w| w.code() == value) {
4499 order.push(value.to_string());
4500 }
4501 } else if let Some(code) = key.strip_prefix("size:") {
4502 if let Some(size) = widget::Size::parse(&value) {
4503 sizes.insert(code.to_string(), size);
4504 }
4505 }
4506 }
4507 for code in order {
4508 let size = sizes.get(&code).copied().unwrap_or_default();
4509 layout.widgets.push(widget::Placed { code, size });
4510 }
4511 let json = serde_json::to_string(&layout).unwrap_or_default();
4512 if let Err(e) = laterite_auth::store::set_user_preference(
4513 &state.db,
4514 user.user.id,
4515 widget::PREFERENCE,
4516 &json,
4517 )
4518 .await
4519 {
4520 tracing::error!(error = %e, "failed to save the dashboard layout");
4521 session.push_flash(FlashLevel::Error, t!("The dashboard could not be saved."));
4522 } else {
4523 session.push_flash(FlashLevel::Success, t!("Dashboard saved."));
4524 }
4525 Redirect::to(&state.admin_path).into_response()
4526}
4527
4528async fn dashboard_layout_reset(
4530 State(state): State<AdminState>,
4531 Extension(user): Extension<AuthenticatedUser>,
4532 Extension(session): Extension<session::SessionHandle>,
4533) -> Response {
4534 if let Err(e) =
4535 laterite_auth::store::clear_user_preference(&state.db, user.user.id, widget::PREFERENCE)
4536 .await
4537 {
4538 tracing::error!(error = %e, "failed to reset the dashboard layout");
4539 }
4540 session.push_flash(FlashLevel::Success, t!("Dashboard reset."));
4541 Redirect::to(&state.admin_path).into_response()
4542}
4543
4544struct AvailableWidgetView {
4546 code: String,
4547 label: String,
4548 description: String,
4549 shown: bool,
4550 size: String,
4551}
4552
4553fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
4556 match &item.permission {
4557 None => true,
4558 Some(p) => perms.allows(p),
4559 }
4560}
4561
4562fn visible_settings(
4566 items: &[settings::SettingsItem],
4567 perms: &PermissionSet,
4568) -> Vec<settings::SettingsItem> {
4569 items
4570 .iter()
4571 .filter(|item| operator_can_see(item, perms))
4572 .cloned()
4573 .collect()
4574}
4575
4576async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
4577 settings::index(shell)
4578}
4579
4580async fn settings_edit(
4581 state: AdminState,
4582 shell: Shell,
4583 user: AuthenticatedUser,
4584 code: String,
4585) -> Response {
4586 let items = visible_settings(&state.settings, &user.permissions);
4589 match items
4590 .iter()
4591 .find(|item| item.code == code && item.link.is_none())
4592 {
4593 Some(item) => settings::edit_form(&state, item, shell, &user).await,
4594 None => not_found(),
4595 }
4596}
4597
4598async fn settings_update(
4599 state: AdminState,
4600 shell: Shell,
4601 user: AuthenticatedUser,
4602 session: session::SessionHandle,
4603 code: String,
4604 data: HashMap<String, String>,
4605) -> Response {
4606 let items = visible_settings(&state.settings, &user.permissions);
4607 match items
4608 .iter()
4609 .find(|item| item.code == code && item.link.is_none())
4610 {
4611 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
4612 None => not_found(),
4613 }
4614}
4615
4616async fn preferences_form(
4618 State(state): State<AdminState>,
4619 Extension(shell): Extension<Shell>,
4620 Extension(user): Extension<AuthenticatedUser>,
4621 jar: CookieJar,
4622) -> Response {
4623 let account = account_security(&state, &user, &shell, &jar).await;
4624 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
4625 .into_iter()
4626 .filter(|i| i.per_operator)
4627 .map(|i| PreferenceLink {
4628 label: shell.tt(&i.label),
4629 description: shell.tt(&i.description),
4630 href: i.path(&state.admin_path),
4631 })
4632 .collect();
4633 let own = OwnMenu::load(&state, user.user.id).await;
4634 let chosen = |key: &str| -> String {
4635 own.moves
4636 .get(key)
4637 .map(|m| {
4638 if m.hierarchy == nav::TOP_HIERARCHY {
4639 "nav".to_string()
4640 } else {
4641 "settings".to_string()
4642 }
4643 })
4644 .unwrap_or_default()
4645 };
4646 let mut menu_entries: Vec<MenuEntryView> = state
4647 .nav_items
4648 .iter()
4649 .filter(|i| i.path.is_some() && !matches!(i.key.as_str(), "dashboard" | "settings"))
4650 .filter(|i| {
4651 i.permission
4652 .as_deref()
4653 .is_none_or(|p| user.permissions.allows(p))
4654 })
4655 .map(|i| MenuEntryView {
4656 key: i.key.clone(),
4657 label: shell.tt(&i.label),
4658 home: "nav",
4659 chosen: chosen(&i.key),
4660 })
4661 .collect();
4662 menu_entries.extend(
4663 visible_settings(&state.settings, &user.permissions)
4664 .into_iter()
4665 .filter(|i| !i.per_operator)
4666 .map(|i| MenuEntryView {
4667 chosen: chosen(&i.code),
4668 key: i.code,
4669 label: shell.tt(&i.label),
4670 home: "settings",
4671 }),
4672 );
4673 render(preferences_view(
4674 &shell,
4675 &user,
4676 state.timezone,
4677 &state.default_locale,
4678 &offered_locales(&state.catalogs),
4679 account,
4680 state.auth.password_policy().min_length,
4681 items,
4682 menu_entries,
4683 ))
4684}
4685
4686struct PreferenceLink {
4688 label: String,
4689 description: String,
4690 href: String,
4691}
4692
4693#[derive(Deserialize)]
4694struct RevokeSessionForm {
4695 id: String,
4696}
4697
4698async fn session_revoke(
4700 State(state): State<AdminState>,
4701 Extension(user): Extension<AuthenticatedUser>,
4702 Extension(session): Extension<session::SessionHandle>,
4703 Form(form): Form<RevokeSessionForm>,
4704) -> Response {
4705 match state.auth.revoke_session(user.user.id, &form.id).await {
4706 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
4707 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
4708 Err(e) => {
4709 tracing::error!(error = %e, "revoking a session failed");
4710 session.push_flash(
4711 FlashLevel::Error,
4712 t!("That session could not be signed out."),
4713 );
4714 }
4715 }
4716 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4717}
4718
4719#[derive(Deserialize)]
4721struct MenuOrderForm {
4722 #[serde(default)]
4723 order: String,
4724 #[serde(default)]
4726 hierarchy: String,
4727}
4728
4729async fn menu_moves_update(
4733 State(state): State<AdminState>,
4734 Extension(user): Extension<AuthenticatedUser>,
4735 Extension(session): Extension<session::SessionHandle>,
4736 Form(form): Form<HashMap<String, String>>,
4737) -> Response {
4738 let mut moves = serde_json::Map::new();
4739 for (field, place) in &form {
4740 let Some(key) = field.strip_prefix("place:") else {
4741 continue;
4742 };
4743 if key.is_empty() || key.len() > 200 || !matches!(place.as_str(), "nav" | "settings") {
4744 continue;
4745 }
4746 moves.insert(key.to_string(), serde_json::Value::String(place.clone()));
4747 }
4748 let result = if moves.is_empty() {
4749 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::MOVES_PREFERENCE)
4750 .await
4751 } else {
4752 laterite_auth::store::set_user_preference(
4753 &state.db,
4754 user.user.id,
4755 nav::MOVES_PREFERENCE,
4756 &serde_json::Value::Object(moves).to_string(),
4757 )
4758 .await
4759 };
4760 if let Err(e) = result {
4761 tracing::error!(error = %e, "storing the menu moves failed");
4762 return render_error();
4763 }
4764 session.push_flash(session::FlashLevel::Success, t!("Menu saved."));
4765 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4766}
4767
4768async fn markdown_preview(
4771 Extension(_user): Extension<AuthenticatedUser>,
4772 Form(form): Form<HashMap<String, String>>,
4773) -> Response {
4774 let text = form.get("text").map(String::as_str).unwrap_or_default();
4775 if text.len() > 1 << 20 {
4776 return (axum::http::StatusCode::PAYLOAD_TOO_LARGE, "text over 1 MiB").into_response();
4777 }
4778 Html(laterite_core::markdown::render(text)).into_response()
4779}
4780
4781async fn editor_menu(
4784 State(state): State<AdminState>,
4785 Extension(shell): Extension<Shell>,
4786 Extension(_user): Extension<AuthenticatedUser>,
4787 Query(query): Query<HashMap<String, String>>,
4788) -> Response {
4789 let Some(surface) = query.get("surface").and_then(|k| editor::Surface::parse(k)) else {
4790 return (axum::http::StatusCode::BAD_REQUEST, "unknown surface").into_response();
4791 };
4792 let registry = &state.plugin_defined;
4793 let mut menu = editor::Menu::default();
4794 for cmd in editor::commands(
4795 registry.items::<editor::EditorCommand>().into_iter(),
4796 surface,
4797 ) {
4798 let (html, text) = match &cmd.insert {
4799 editor::Insert::Html(h) => (Some(h.clone()), None),
4800 editor::Insert::Text(t) => (None, Some(t.clone())),
4801 };
4802 menu.commands.push(editor::MenuCommand {
4803 key: cmd.key.clone(),
4804 label: shell.tt(&cmd.label),
4805 html,
4806 text,
4807 });
4808 }
4809 for source in registry.items::<editor::SnippetSourceReg>() {
4810 menu.snippets.extend(source.0.list(&state.db).await);
4811 }
4812 menu.snippets.sort_by(|a, b| a.name.cmp(&b.name));
4813 axum::Json(menu).into_response()
4814}
4815
4816async fn menu_order_update(
4821 State(state): State<AdminState>,
4822 Extension(user): Extension<AuthenticatedUser>,
4823 Extension(session): Extension<session::SessionHandle>,
4824 headers: axum::http::HeaderMap,
4825 Form(form): Form<MenuOrderForm>,
4826) -> Response {
4827 let order: Vec<&str> = form
4828 .order
4829 .split(',')
4830 .map(str::trim)
4831 .filter(|k| !k.is_empty() && k.len() <= 200)
4832 .take(100)
4833 .collect();
4834 let hierarchy = if form.hierarchy.is_empty() {
4835 nav::TOP_HIERARCHY
4836 } else {
4837 form.hierarchy.as_str()
4838 };
4839 if !nav::OPERATOR_HIERARCHIES.contains(&hierarchy) {
4840 return AdminError::NotFound.into_response();
4841 }
4842 let key = nav::order_preference(hierarchy);
4843 let result = if order.is_empty() {
4844 let mut cleared = Ok(());
4846 for key in nav::OPERATOR_HIERARCHIES
4847 .iter()
4848 .map(|h| nav::order_preference(h))
4849 .chain(std::iter::once(nav::MOVES_PREFERENCE.to_string()))
4850 {
4851 if let Err(e) =
4852 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
4853 {
4854 cleared = Err(e);
4855 }
4856 }
4857 cleared
4858 } else {
4859 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &order.join(","))
4860 .await
4861 };
4862 if let Err(e) = result {
4863 tracing::error!(error = %e, "storing the menu order failed");
4864 return render_error();
4865 }
4866 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
4867 return axum::http::StatusCode::NO_CONTENT.into_response();
4868 }
4869 session.push_flash(
4870 FlashLevel::Success,
4871 if order.is_empty() {
4872 t!("The menu is back in its default order.")
4873 } else {
4874 t!("Saved.")
4875 },
4876 );
4877 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4878}
4879
4880async fn session_revoke_others(
4881 State(state): State<AdminState>,
4882 Extension(user): Extension<AuthenticatedUser>,
4883 Extension(session): Extension<session::SessionHandle>,
4884 jar: CookieJar,
4885) -> Response {
4886 let token = jar
4887 .get(SESSION_COOKIE)
4888 .map(|c| c.value().to_string())
4889 .unwrap_or_default();
4890 match state.auth.sign_out_everywhere(user.user.id, &token).await {
4891 Ok(_) => session.push_flash(
4892 FlashLevel::Success,
4893 t!("Every other session was signed out."),
4894 ),
4895 Err(e) => {
4896 tracing::error!(error = %e, "signing out other sessions failed");
4897 session.push_flash(
4898 FlashLevel::Error,
4899 t!("Those sessions could not be signed out."),
4900 );
4901 }
4902 }
4903 (
4906 jar.remove(remember_removal(&state.cookie_path)),
4907 Redirect::to(&format!("{}/preferences", state.admin_path)),
4908 )
4909 .into_response()
4910}
4911
4912#[derive(Deserialize)]
4913struct PasswordForm {
4914 current_password: String,
4915 new_password: String,
4916 confirm_password: String,
4917}
4918
4919async fn password_update(
4924 State(state): State<AdminState>,
4925 Extension(user): Extension<AuthenticatedUser>,
4926 Extension(session): Extension<session::SessionHandle>,
4927 Extension(ctx): Extension<RequestContext>,
4928 jar: CookieJar,
4929 Form(form): Form<PasswordForm>,
4930) -> Response {
4931 let back = format!("{}/preferences#password", state.admin_path);
4932 let refuse = |message: Text| {
4933 session.push_flash(FlashLevel::Error, message);
4934 Redirect::to(&back).into_response()
4935 };
4936 if form.new_password != form.confirm_password {
4937 return refuse(t!("The new passwords do not match."));
4938 }
4939 let min_length = state.auth.password_policy().min_length;
4940 if form.new_password.chars().count() < min_length {
4941 return refuse(t!(
4942 "A password needs at least {n} characters.",
4943 n = min_length as i64
4944 ));
4945 }
4946 let remembered_here = match jar.get(REMEMBER_COOKIE) {
4949 Some(cookie) => {
4950 state
4951 .auth
4952 .remember_holder(cookie.value())
4953 .await
4954 .ok()
4955 .flatten()
4956 == Some(user.user.id)
4957 }
4958 None => false,
4959 };
4960 let token = jar
4961 .get(SESSION_COOKIE)
4962 .map(|c| c.value().to_string())
4963 .unwrap_or_default();
4964 let changed = state
4965 .auth
4966 .change_own_password(
4967 user.user.id,
4968 &form.current_password,
4969 &form.new_password,
4970 &token,
4971 &ctx,
4972 )
4973 .await;
4974 match changed {
4975 Ok(()) => {}
4976 Err(laterite_auth::AuthError::InvalidCredentials) => {
4977 return refuse(t!("Your current password is not correct."));
4978 }
4979 Err(laterite_auth::AuthError::TooManyAttempts) => {
4980 return refuse(t!("Too many attempts. Try again later."));
4981 }
4982 Err(e) => {
4983 tracing::error!(error = %e, "changing a password failed");
4984 return refuse(t!("The password could not be changed."));
4985 }
4986 }
4987 session.push_flash_sticky(
4988 FlashLevel::Success,
4989 t!("Password changed. Every other device was signed out."),
4990 );
4991 let jar = if remembered_here {
4992 match state.auth.issue_remember(user.user.id).await {
4993 Ok(credential) => jar.add(remember_cookie(
4994 credential,
4995 &state.cookie_path,
4996 state.secure_cookie,
4997 )),
4998 Err(e) => {
4999 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
5000 jar.remove(remember_removal(&state.cookie_path))
5001 }
5002 }
5003 } else if jar.get(REMEMBER_COOKIE).is_some() {
5004 jar.remove(remember_removal(&state.cookie_path))
5007 } else {
5008 jar
5009 };
5010 (
5011 jar,
5012 Redirect::to(&format!("{}/preferences", state.admin_path)),
5013 )
5014 .into_response()
5015}
5016
5017#[derive(Deserialize)]
5018struct PreferencesForm {
5019 timezone: String,
5020 #[serde(default)]
5022 locale: String,
5023}
5024
5025async fn preferences_update(
5026 State(state): State<AdminState>,
5027 Extension(user): Extension<AuthenticatedUser>,
5028 Extension(session): Extension<session::SessionHandle>,
5029 Form(form): Form<PreferencesForm>,
5030) -> Response {
5031 let back = format!("{}/preferences", state.admin_path);
5032 let offered = offered_locales(&state.catalogs);
5033 let tz = form.timezone.trim();
5035 let tz_stored = if tz.is_empty() {
5036 None
5037 } else if tz.parse::<Tz>().is_ok() {
5038 Some(tz)
5039 } else {
5040 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
5041 return Redirect::to(&back).into_response();
5042 };
5043 let loc = form.locale.trim();
5044 let loc_stored = if loc.is_empty() {
5045 None
5046 } else if offered.iter().any(|l| l == loc) {
5047 Some(loc)
5048 } else {
5049 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
5050 return Redirect::to(&back).into_response();
5051 };
5052 if state
5053 .auth
5054 .set_user_timezone(user.user.id, tz_stored)
5055 .await
5056 .is_err()
5057 {
5058 return render_error();
5059 }
5060 match state.auth.set_user_locale(user.user.id, loc_stored).await {
5061 Ok(()) => {
5062 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
5063 Redirect::to(&back).into_response()
5064 }
5065 Err(_) => render_error(),
5066 }
5067}
5068
5069#[allow(clippy::too_many_arguments)]
5073fn preferences_view(
5074 shell: &Shell,
5075 user: &AuthenticatedUser,
5076 default_tz: Tz,
5077 default_locale: &str,
5078 offered: &[String],
5079 account: AccountSecurity,
5080 min_password: usize,
5081 items: Vec<PreferenceLink>,
5082 menu_entries: Vec<MenuEntryView>,
5083) -> PreferencesTemplate {
5084 let current = user.user.timezone.as_deref();
5085 let zones = TZ_VARIANTS
5086 .iter()
5087 .filter(|tz| zone_offered(tz.name()))
5088 .map(|tz| TzOption {
5089 name: tz.name().to_string(),
5090 selected: current == Some(tz.name()),
5091 })
5092 .collect();
5093 let current_locale = user.user.locale.as_deref();
5094 let locales = offered
5095 .iter()
5096 .map(|code| LocaleOption {
5097 name: locale_name(code),
5098 selected: current_locale == Some(code.as_str()),
5099 code: code.clone(),
5100 })
5101 .collect();
5102 PreferencesTemplate {
5103 shell: shell.clone(),
5104 zones,
5105 effective_tz: shell.tz.name().to_string(),
5106 default_tz: default_tz.name().to_string(),
5107 inherits: current.is_none(),
5108 locales,
5109 default_locale: locale_name(default_locale),
5110 inherits_locale: current_locale.is_none(),
5111 sessions: account.sessions,
5112 password_changed: account.password_changed,
5113 must_change: account.must_change,
5114 min_password: min_password as i64,
5115 items,
5116 menu_entries,
5117 }
5118}
5119
5120struct AccountSecurity {
5123 sessions: Vec<SessionRow>,
5124 password_changed: Option<String>,
5125 must_change: bool,
5127}
5128
5129async fn account_security(
5130 state: &AdminState,
5131 user: &AuthenticatedUser,
5132 shell: &Shell,
5133 jar: &CookieJar,
5134) -> AccountSecurity {
5135 let locale = list::date_locale(shell.locale());
5136 let password_changed = match state.auth.password_changed_at(user.user.id).await {
5137 Ok(at) => {
5138 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
5139 }
5140 Err(e) => {
5141 tracing::error!(error = %e, "reading the password change time failed");
5142 None
5143 }
5144 };
5145 AccountSecurity {
5146 sessions: session_rows(state, user, shell, jar).await,
5147 password_changed,
5148 must_change: state
5149 .auth
5150 .must_change_password(user.user.id)
5151 .await
5152 .unwrap_or(false),
5153 }
5154}
5155
5156async fn session_rows(
5159 state: &AdminState,
5160 user: &AuthenticatedUser,
5161 shell: &Shell,
5162 jar: &CookieJar,
5163) -> Vec<SessionRow> {
5164 let token = jar
5165 .get(SESSION_COOKIE)
5166 .map(|c| c.value().to_string())
5167 .unwrap_or_default();
5168 let locale = list::date_locale(shell.locale());
5169 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
5170 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
5171 };
5172 match state.auth.list_sessions(user.user.id, &token).await {
5173 Ok(sessions) => sessions
5174 .into_iter()
5175 .map(|s| SessionRow {
5176 id: s.id,
5177 started: stamp(s.created_at),
5178 last_seen: stamp(s.last_seen_at),
5179 expires: stamp(s.expires_at),
5180 device: clientip::describe(s.user_agent.as_deref())
5181 .or(s.user_agent)
5182 .unwrap_or_else(|| "-".to_string()),
5183 ip_address: s.ip_address.unwrap_or_default(),
5184 current: s.current,
5185 })
5186 .collect(),
5187 Err(e) => {
5188 tracing::error!(error = %e, "listing sessions failed");
5189 Vec::new()
5190 }
5191 }
5192}
5193
5194fn builtin_resources() -> Vec<Resource> {
5196 vec![
5197 Resource {
5198 base_path: "/users".to_string(),
5199 nav_label: "Administrators".into(),
5200 list: backend_users_list_config(),
5201 form: None,
5202 permission: Some("backend.manage_users".to_string()),
5203 import: None,
5204 palette: None,
5205 relations: Vec::new(),
5206 menu: nav::Placement::default(),
5207 },
5208 Resource {
5209 base_path: "/roles".to_string(),
5210 nav_label: "Roles".into(),
5211 list: roles_list_config(),
5212 form: None,
5215 permission: Some("backend.manage_roles".to_string()),
5216 import: None,
5217 palette: None,
5218 relations: Vec::new(),
5219 menu: nav::Placement::default(),
5220 },
5221 resource!("descriptors/audit-log.yaml"),
5222 resource!("descriptors/access-log.yaml"),
5223 resource!("descriptors/request-log.yaml"),
5224 ]
5225}
5226
5227fn builtin_settings() -> Vec<settings::SettingsItem> {
5231 vec![
5232 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
5233 .description("Manage backend administrator accounts.")
5234 .category("Users")
5235 .slot(laterite_core::arrange::slot::PEOPLE)
5236 .order(10)
5237 .icon("users")
5238 .permission("backend.manage_users")
5239 .link("/users"),
5240 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
5241 .description("Manage roles and their permissions.")
5242 .category("Users")
5243 .slot(laterite_core::arrange::slot::PEOPLE)
5244 .order(20)
5245 .icon("shield")
5246 .permission("backend.manage_roles")
5247 .link("/roles"),
5248 settings::brand::settings_item(),
5249 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
5250 .description("Enable or disable installed plugins.")
5251 .category("System")
5252 .slot(laterite_core::arrange::slot::SYSTEM)
5253 .order(10)
5254 .icon("plug")
5255 .permission(plugins::MANAGE_PERMISSION)
5256 .link("/plugins"),
5257 settings::SettingsItem::new("backend.jobs", "Jobs", Vec::new())
5258 .description("Watch the queues and retry or drop failed jobs.")
5259 .category("System")
5260 .slot(laterite_core::arrange::slot::SYSTEM)
5261 .order(12)
5262 .icon("layers")
5263 .permission(jobs::PERMISSION)
5264 .link("/jobs"),
5265 settings::SettingsItem::new("backend.scheduled_tasks", "Scheduled Tasks", Vec::new())
5266 .description("See when each scheduled entry last ran and run one now.")
5267 .category("System")
5268 .slot(laterite_core::arrange::slot::SYSTEM)
5269 .order(13)
5270 .icon("clock")
5271 .permission(scheduled::PERMISSION)
5272 .link("/scheduled-tasks"),
5273 settings::mail::settings_item(),
5274 settings::SettingsItem::new("backend.mail_templates", "Mail Templates", Vec::new())
5275 .description("Edit the templates, layouts and partials mail is built from.")
5276 .category("System")
5277 .slot(laterite_core::arrange::slot::SYSTEM)
5278 .order(15)
5279 .icon("layout-template")
5280 .permission(mailtemplates::PERMISSION)
5281 .link("/mail-templates"),
5282 settings::mail_brand::settings_item(),
5283 settings::SettingsItem::new("backend.mail_log", "Mail Log", Vec::new())
5284 .description("Read the messages the log driver kept.")
5285 .category("System")
5286 .slot(laterite_core::arrange::slot::SYSTEM)
5287 .order(17)
5288 .icon("file-text")
5289 .permission(settings::mail::PERMISSION)
5290 .link("/mail-log"),
5291 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
5292 .description("Review the record of administrative changes.")
5293 .category("System")
5294 .slot(laterite_core::arrange::slot::SYSTEM)
5295 .order(20)
5296 .icon("history")
5297 .permission("backend.view_audit_log")
5298 .link("/audit-log"),
5299 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
5300 .description("Review sign-ins, failures and lockouts.")
5301 .category("System")
5302 .slot(laterite_core::arrange::slot::SYSTEM)
5303 .order(30)
5304 .icon("log-in")
5305 .permission("backend.view_access_log")
5306 .link("/access-log"),
5307 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
5308 .description("Review the URLs the panel could not serve.")
5309 .category("System")
5310 .slot(laterite_core::arrange::slot::SYSTEM)
5311 .order(31)
5312 .icon("unlink")
5313 .permission("backend.view_request_log")
5314 .link("/request-log"),
5315 ]
5316}
5317
5318fn backend_users_list_config() -> list::ListConfig {
5319 list::ListConfig {
5320 entity: "backend_users".to_string(),
5321 title: "Administrators".into(),
5322 columns: vec![
5323 list::ListColumn::new("username", "Username"),
5324 list::ListColumn::new("email", "Email"),
5325 list::ListColumn::new("first_name", "First name"),
5326 list::ListColumn::new("last_name", "Last name"),
5327 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
5328 list::ListColumn::new("is_active", "Active").yes_no(),
5329 list::ListColumn::new("created_at", "Created").datetime(),
5330 ],
5331 order_by: "created_at".to_string(),
5332 creatable: true,
5335 edit_base: Some("/users".to_string()),
5336 filters: vec![
5337 list::ListFilter::boolean("is_active", "Active"),
5338 list::ListFilter::boolean("is_superuser", "Superuser"),
5339 ],
5340 ..Default::default()
5344 }
5345}
5346
5347fn roles_list_config() -> list::ListConfig {
5348 list::ListConfig {
5349 entity: "backend_roles".to_string(),
5350 title: "Roles".into(),
5351 columns: vec![
5352 list::ListColumn::new("code", "Code"),
5353 list::ListColumn::new("name", "Name"),
5354 list::ListColumn::new("created_at", "Created").datetime(),
5355 ],
5356 order_by: "created_at".to_string(),
5357 edit_base: Some("/roles".to_string()),
5358 creatable: true,
5359 deletable: true,
5360 ..Default::default()
5361 }
5362}
5363
5364#[derive(Template)]
5365#[template(path = "login.html")]
5366struct LoginTemplate {
5367 base: String,
5369 asset_urls: Arc<AssetUrls>,
5371 brand: String,
5372 look: Arc<settings::brand::Brand>,
5373 skin: Arc<skin::Active>,
5374 error: Option<String>,
5375 notice: Option<String>,
5377 i18n: Translator,
5380}
5381
5382pub(crate) fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
5385 match urls.get(key) {
5386 Some(path) => format!("{base}/assets/{path}"),
5387 None => {
5388 debug_assert!(false, "asset key `{key}` is not registered");
5389 format!("{base}/assets/{key}")
5390 }
5391 }
5392}
5393
5394impl LoginTemplate {
5395 fn asset(&self, key: &str) -> String {
5396 preauth_asset(&self.asset_urls, &self.base, key)
5397 }
5398 fn t(&self, source: &str) -> String {
5399 self.i18n.t(&Text::dynamic(source))
5400 }
5401 fn locale(&self) -> &str {
5402 self.i18n.locale()
5403 }
5404}
5405
5406#[derive(Template)]
5407#[template(path = "dashboard.html")]
5408struct DashboardTemplate {
5409 shell: Shell,
5410 widgets: Vec<String>,
5412 available: Vec<AvailableWidgetView>,
5413}
5414
5415#[derive(Template)]
5416#[template(path = "setup.html")]
5417struct SetupTemplate {
5418 base: String,
5420 asset_urls: Arc<AssetUrls>,
5422 brand: String,
5423 look: Arc<settings::brand::Brand>,
5424 skin: Arc<skin::Active>,
5425 zones: Vec<TzOption>,
5426 error: Option<String>,
5427 i18n: Translator,
5429 min_password: i64,
5431}
5432
5433impl SetupTemplate {
5434 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
5437 let mut text = Text::dynamic(source);
5438 for (name, value) in args {
5439 text = text.arg(*name, *value);
5440 }
5441 self.i18n.t(&text)
5442 }
5443
5444 fn asset(&self, key: &str) -> String {
5445 preauth_asset(&self.asset_urls, &self.base, key)
5446 }
5447 fn t(&self, source: &str) -> String {
5448 self.i18n.t(&Text::dynamic(source))
5449 }
5450 fn locale(&self) -> &str {
5451 self.i18n.locale()
5452 }
5453}
5454
5455#[derive(Template)]
5456#[template(path = "preferences.html")]
5457struct PreferencesTemplate {
5458 shell: Shell,
5459 zones: Vec<TzOption>,
5460 effective_tz: String,
5462 default_tz: String,
5464 inherits: bool,
5466 locales: Vec<LocaleOption>,
5467 default_locale: String,
5469 inherits_locale: bool,
5471 sessions: Vec<SessionRow>,
5473 password_changed: Option<String>,
5475 must_change: bool,
5477 min_password: i64,
5479 items: Vec<PreferenceLink>,
5481 menu_entries: Vec<MenuEntryView>,
5483}
5484
5485struct MenuEntryView {
5488 key: String,
5489 label: String,
5490 home: &'static str,
5492 chosen: String,
5494}
5495
5496struct SessionRow {
5498 id: String,
5499 started: String,
5500 last_seen: String,
5501 expires: String,
5502 device: String,
5505 ip_address: String,
5507 current: bool,
5508}
5509
5510struct TzOption {
5511 name: String,
5512 selected: bool,
5513}
5514
5515struct LocaleOption {
5516 code: String,
5518 name: String,
5520 selected: bool,
5521}
5522
5523#[derive(Clone)]
5525struct CrumbView {
5526 label: String,
5527 href: String,
5528}
5529
5530#[derive(Clone)]
5531struct NavView {
5532 key: String,
5534 label: String,
5535 path: String,
5536 active: bool,
5537 icon: String,
5540}
5541
5542pub(crate) fn render<T: Template>(template: T) -> Response {
5544 match template.render() {
5545 Ok(html) => Html(html).into_response(),
5546 Err(e) => AdminError::from(e).into_response(),
5548 }
5549}
5550
5551pub(crate) fn render_error() -> Response {
5552 error::masked_500()
5553}
5554
5555pub(crate) fn not_found() -> Response {
5556 AdminError::NotFound.into_response()
5557}
5558
5559pub(crate) fn forbidden() -> Response {
5560 AdminError::Forbidden.into_response()
5561}
5562
5563#[cfg(test)]
5564mod tests {
5565 use super::*;
5566
5567 fn write_form() -> form::FormConfig {
5568 form::FormConfig {
5569 enter: Default::default(),
5570 confirm_leave: true,
5571 focus: Default::default(),
5572 entity: "widgets".to_string(),
5573 title: "Widgets".into(),
5574 base_path: "/widgets".to_string(),
5575 id_field: "id".to_string(),
5576 fields: vec![],
5577 persist: None,
5578 timestamps: false,
5579 done_path: None,
5580 }
5581 }
5582
5583 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
5584 Resource {
5585 base_path: "/widgets".to_string(),
5586 nav_label: "Widgets".into(),
5587 list: backend_users_list_config(),
5588 form,
5589 permission: permission.map(str::to_string),
5590 import: None,
5591 palette: None,
5592 relations: Vec::new(),
5593 menu: nav::Placement::default(),
5594 }
5595 }
5596
5597 #[test]
5598 #[should_panic(expected = "must declare a permission")]
5599 fn write_resource_without_permission_aborts_boot() {
5600 let resource = resource_with(Some(write_form()), None);
5603 let _ = mount_resource(
5604 &resource,
5605 &field::builtin_registry(),
5606 &list::builtin_column_registry(),
5607 &std::collections::HashSet::new(),
5608 &persist::PersisterRegistry::new(),
5609 &[],
5610 );
5611 }
5612
5613 #[test]
5615 fn the_access_log_is_read_from_its_descriptor_file() {
5616 let access = builtin_resources()
5617 .into_iter()
5618 .find(|r| r.base_path == "/access-log")
5619 .expect("the access log is registered");
5620 assert_eq!(access.list.entity, "backend_access_log");
5621 assert_eq!(
5622 access.permission.as_deref(),
5623 Some("backend.view_access_log")
5624 );
5625 assert!(access.form.is_none(), "append-only");
5626 assert_eq!(
5627 access
5628 .list
5629 .columns
5630 .iter()
5631 .map(|c| c.field.as_str())
5632 .collect::<Vec<_>>(),
5633 [
5634 "created_at",
5635 "username_attempted",
5636 "event",
5637 "ip_address",
5638 "user_agent"
5639 ]
5640 );
5641 }
5642
5643 #[test]
5646 fn the_audit_log_is_read_from_its_descriptor_file() {
5647 let audit = builtin_resources()
5648 .into_iter()
5649 .find(|r| r.base_path == "/audit-log")
5650 .expect("the audit log is registered");
5651 assert_eq!(audit.list.entity, "backend_audit_log");
5652 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
5653 assert_eq!(
5654 audit
5655 .list
5656 .columns
5657 .iter()
5658 .map(|c| c.field.as_str())
5659 .collect::<Vec<_>>(),
5660 [
5661 "created_at",
5662 "actor_username",
5663 "action",
5664 "target_type",
5665 "target_id",
5666 "target_label"
5667 ],
5668 "in the order the file writes them"
5669 );
5670 assert_eq!(audit.list.columns[0].column_type, "datetime");
5671 assert_eq!(audit.list.per_page, 50);
5672 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
5673 assert!(audit.list.exportable, "evidence may leave the panel");
5674 assert!(audit.form.is_none(), "append-only: no form");
5675 assert!(!audit.list.deletable, "and nothing removes from it");
5676 }
5677
5678 #[test]
5679 #[should_panic(expected = "uses unregistered type `nope`")]
5680 fn unregistered_column_type_aborts_boot() {
5681 let mut resource = resource_with(None, None);
5682 let mut score = list::ListColumn::new("score", "Score");
5683 score.column_type = "nope".into();
5684 resource.list.columns.push(score);
5685 let _ = mount_resource(
5686 &resource,
5687 &field::builtin_registry(),
5688 &list::builtin_column_registry(),
5689 &std::collections::HashSet::new(),
5690 &persist::PersisterRegistry::new(),
5691 &[],
5692 );
5693 }
5694
5695 #[test]
5696 fn read_only_resource_may_omit_permission() {
5697 let resource = resource_with(None, None);
5699 let _ = mount_resource(
5700 &resource,
5701 &field::builtin_registry(),
5702 &list::builtin_column_registry(),
5703 &std::collections::HashSet::new(),
5704 &persist::PersisterRegistry::new(),
5705 &[],
5706 );
5707 }
5708
5709 #[test]
5710 fn shell_localizes_through_the_translator_and_falls_back() {
5711 let shell = Shell::test();
5713 assert_eq!(shell.t("Save"), "Save");
5714 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
5715 }
5716
5717 #[test]
5718 fn shell_tf_interpolates_integer_arguments() {
5719 let shell = Shell::test();
5720 assert_eq!(
5721 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
5722 "Page 2 of 5"
5723 );
5724 }
5725
5726 #[test]
5727 fn display_tz_prefers_a_valid_operator_preference() {
5728 assert_eq!(
5729 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
5730 Tz::Asia__Kolkata
5731 );
5732 }
5733
5734 #[test]
5735 fn display_tz_falls_back_when_unset_or_invalid() {
5736 let default = Tz::Europe__London;
5737 assert_eq!(resolve_display_tz(None, default), default);
5739 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
5741 assert_eq!(resolve_display_tz(Some(""), default), default);
5742 }
5743
5744 #[test]
5745 fn accept_language_orders_by_weight_and_drops_wildcard() {
5746 assert_eq!(
5747 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
5748 vec!["en-US", "en", "kn"]
5749 );
5750 assert_eq!(
5752 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
5753 vec!["kn", "en"]
5754 );
5755 }
5756
5757 #[test]
5758 fn locale_chain_prefers_operator_then_header_then_default() {
5759 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
5761 assert_eq!(
5764 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
5765 vec!["kn", "ta", "hi", "en"]
5766 );
5767 assert_eq!(
5769 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
5770 vec!["kn", "en", "ta"]
5771 );
5772 assert_eq!(
5774 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
5775 vec!["kn", "en"]
5776 );
5777 }
5778
5779 #[test]
5780 fn locale_chain_skips_unserveable_and_defaults_to_en() {
5781 let loaded = ["hi".to_string()];
5783 assert_eq!(
5786 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
5787 vec!["en"]
5788 );
5789 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
5791 assert_eq!(
5793 resolve_locale_chain(Some("hi"), None, "en", &loaded),
5794 vec!["hi", "en"]
5795 );
5796 assert_eq!(
5798 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
5799 vec![laterite_core::PSEUDO_LOCALE, "en"]
5800 );
5801 }
5802
5803 #[test]
5804 fn a_page_with_an_aside_renders_the_fold_beside_the_body() {
5805 use askama::Template;
5806 #[derive(askama::Template)]
5807 #[template(source = "<p>{{ text }}</p>", ext = "html")]
5808 struct Para {
5809 text: &'static str,
5810 }
5811 let para = |text| html::Markup::from_template(&Para { text }).unwrap();
5812 let page = PageTemplate {
5813 title: "Spec".to_string(),
5814 back: None,
5815 actions: None,
5816 aside: Some(("Chapters".to_string(), para("one"))),
5817 body: para("body"),
5818 shell: Shell::test(),
5819 };
5820 let html = page.render().unwrap();
5821 let panel = html.find("<aside class=\"lat-aside\"").expect("aside");
5822 let body = html.find("<p>body</p>").expect("body");
5823 assert!(panel < body, "the panel comes before the body: {html}");
5824 assert!(html.contains("data-lat-widget=\"aside\""));
5825 assert!(html.contains("<summary class=\"lat-aside__summary\">Chapters</summary>"));
5826 assert!(html.contains("aria-label=\"Chapters\""));
5827 let plain = PageTemplate {
5828 title: "Spec".to_string(),
5829 back: None,
5830 actions: None,
5831 aside: None,
5832 body: para("body"),
5833 shell: Shell::test(),
5834 };
5835 assert!(!plain.render().unwrap().contains("lat-aside"));
5836 }
5837
5838 #[test]
5839 fn page_head_assets_dedupe_and_keep_order() {
5840 let head = PageHead::new("Specification")
5841 .assets(CODE_EDITOR_ASSETS)
5842 .assets(&["fields/codeeditor.js", "fields/ref-picker.js"]);
5843 assert_eq!(
5844 head.assets,
5845 vec![
5846 "vendor/codeeditor.js",
5847 "fields/codeeditor.js",
5848 "fields/codeeditor.css",
5849 "fields/ref-picker.js"
5850 ]
5851 );
5852 }
5853
5854 #[test]
5855 fn page_assets_dedupe_preserve_order_and_classify() {
5856 let mut reg = AssetRegistry::new();
5857 reg.insert(
5858 "a.js",
5859 AdminAsset {
5860 mime: "text/javascript; charset=utf-8",
5861 bytes: std::borrow::Cow::Borrowed(b"//"),
5862 },
5863 );
5864 reg.insert(
5865 "b.css",
5866 AdminAsset {
5867 mime: "text/css; charset=utf-8",
5868 bytes: std::borrow::Cow::Borrowed(b"body{}"),
5869 },
5870 );
5871 let urls = asset_urls(®);
5874 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
5875 assert_eq!(assets.len(), 2);
5876 assert_eq!(
5877 assets[0].url,
5878 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
5879 );
5880 assert!(!assets[0].css);
5881 assert_eq!(
5882 assets[1].url,
5883 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
5884 );
5885 assert!(assets[1].css);
5886 assert_ne!(assets[0].url, assets[1].url);
5889 }
5890
5891 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
5895 laterite_core::testing::connect_test(&[]).await
5896 }
5897
5898 #[tokio::test]
5899 async fn builtin_migrations_create_the_admin_tables() {
5900 let (db, _guard) = empty_db().await;
5901 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
5902 .await
5903 .unwrap();
5904 for table in ["backend_users", "settings"] {
5908 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
5909 .fetch_optional(&db.pool)
5910 .await;
5911 assert!(
5912 probe.is_ok(),
5913 "{table} should exist after builtin_migrations"
5914 );
5915 }
5916 }
5917
5918 #[tokio::test]
5919 async fn brand_setting_overrides_config_and_blank_falls_back() {
5920 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
5921 let state = AdminState {
5922 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
5923 db: db.clone(),
5924 parameters: laterite_core::parameters::Parameters::new(db.clone()),
5925 upload_extensions: Arc::new(upload_extensions(&[])),
5926 nav: Arc::new(nav::NavTree::default()),
5927 nav_items: Arc::new(Vec::new()),
5928 settings: Arc::new(Vec::new()),
5929 permissions: Arc::new(builtin_permissions()),
5930 trusted_proxies: Arc::new(Vec::new()),
5931 icons: Arc::new(laterite_core::icons::IconSet::new()),
5932 sprite_url: Arc::from(""),
5933 admin_path: Arc::from("/admin"),
5934 cookie_path: Arc::from("/admin"),
5935 storage_dir: Arc::from("storage"),
5936 crypt: Arc::new(laterite_core::Crypt::ephemeral()),
5937 order: std::collections::BTreeMap::new(),
5938 secure_cookie: false,
5939 origin: Arc::from(""),
5940 timezone: Tz::UTC,
5941 default_locale: Arc::from("en"),
5942 catalogs: Arc::new(CatalogStore::default()),
5943 app_name: "Configured Name".to_string(),
5944 cache: laterite_core::Cache::local(),
5945 queue: inline_queue(&db, laterite_core::Cache::local()),
5946 schedule: empty_schedule(
5947 &db,
5948 laterite_core::Cache::local(),
5949 inline_queue(&db, laterite_core::Cache::local()),
5950 ),
5951 mail: stub_mail(
5952 &db,
5953 laterite_core::Cache::local(),
5954 inline_queue(&db, laterite_core::Cache::local()),
5955 ),
5956 mail_config: Arc::new(laterite_core::config::MailConfig::default()),
5957 skin: Arc::new(skin::Active::default()),
5958 field_types: Arc::new(field::builtin_registry()),
5959 column_types: Arc::new(list::builtin_column_registry()),
5960 widgets: Arc::new(widgets::builtin()),
5961 plugin_defined: Arc::new(laterite_core::Registry::new()),
5962 assets: Arc::new(builtin_assets()),
5963 asset_urls: Arc::new(asset_urls(&builtin_assets())),
5964 pickers: Arc::new(picker::PickerRegistry::new()),
5965 palette: Arc::new(Vec::new()),
5966 overrides: Arc::new(field::NoOverrides),
5967 };
5968
5969 assert_eq!(state.brand().await.name, "Configured Name");
5971
5972 state
5975 .settings_store()
5976 .save(&settings::BrandSetting {
5977 app_name: "Acme Corp".to_string(),
5978 ..Default::default()
5979 })
5980 .await
5981 .unwrap();
5982 assert_eq!(state.brand().await.name, "Acme Corp");
5983
5984 state
5986 .settings_store()
5987 .save(&settings::BrandSetting {
5988 app_name: " ".to_string(),
5989 ..Default::default()
5990 })
5991 .await
5992 .unwrap();
5993 assert_eq!(state.brand().await.name, "Configured Name");
5994 }
5995
5996 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
5997 settings::SettingsItem {
5998 code: code.to_string(),
5999 label: code.into(),
6000 description: String::new().into(),
6001 category: "General".into(),
6002 hint: None,
6003 confirm_leave: true,
6004 slot: laterite_core::arrange::slot::APPLICATION,
6005 order: 1,
6006 icon: None,
6007 permission: permission.map(str::to_string),
6008 link: None,
6009 fields: Vec::new(),
6010 route: None,
6011 per_operator: false,
6012 actions: Vec::new(),
6013 }
6014 }
6015
6016 #[test]
6017 fn settings_visibility_respects_permissions() {
6018 let items = vec![
6019 settings_item("public", None),
6020 settings_item("gated", Some("backend.manage_users")),
6021 ];
6022
6023 let none = PermissionSet::new(false, Vec::<String>::new());
6025 let codes: Vec<String> = visible_settings(&items, &none)
6026 .into_iter()
6027 .map(|i| i.code)
6028 .collect();
6029 assert_eq!(codes, ["public"]);
6030
6031 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
6033 assert_eq!(visible_settings(&items, &granted).len(), 2);
6034
6035 let superuser = PermissionSet::new(true, Vec::<String>::new());
6037 assert_eq!(visible_settings(&items, &superuser).len(), 2);
6038 }
6039
6040 #[test]
6041 fn context_sidebar_follows_settings_links() {
6042 let items = builtin_settings();
6043 let superuser = PermissionSet::new(true, Vec::<String>::new());
6044 let tr = Translator::new("en");
6045 let tree = nav::NavTree::default();
6046 let sidebar = |path: &str| {
6047 resolve_nav_context(
6048 &tree,
6049 &items,
6050 "/admin",
6051 &superuser,
6052 None,
6053 path,
6054 &tr,
6055 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
6056 &std::collections::BTreeMap::new(),
6057 None,
6058 )
6059 .1
6060 };
6061 let active_path = |path: &str| -> Option<String> {
6062 sidebar(path)
6063 .into_iter()
6064 .flat_map(|g| g.items)
6065 .find(|i| i.active)
6066 .map(|i| i.path)
6067 };
6068
6069 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
6071 assert_eq!(
6072 active_path("/admin/roles/42/edit").as_deref(),
6073 Some("/admin/roles")
6074 );
6075 assert!(!sidebar("/admin/settings").is_empty());
6077 assert_eq!(active_path("/admin/settings"), None);
6078 assert_eq!(
6083 active_path("/admin/settings/branding").as_deref(),
6084 Some("/admin/settings/branding")
6085 );
6086 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
6089 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
6090 assert!(sidebar("/admin").is_empty());
6092 }
6093}
6094
6095#[cfg(test)]
6096mod session_cookie_tests {
6097 use super::*;
6098
6099 #[test]
6103 fn the_session_cookie_never_outlives_the_browser() {
6104 let cookie = session_cookie("tok".into(), "/admin", false);
6105 assert_eq!(cookie.max_age(), None);
6106 assert!(cookie.http_only().unwrap_or(false));
6107 assert_eq!(cookie.path(), Some("/admin"));
6108 }
6109
6110 #[test]
6113 fn the_remember_cookie_carries_its_own_lifetime() {
6114 let credential = laterite_auth::RememberCredential {
6115 cookie: "sel:ver".into(),
6116 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
6117 };
6118 let cookie = remember_cookie(credential, "/admin", true);
6119 let age = cookie.max_age().expect("a remember cookie must persist");
6120 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
6122 assert!(cookie.http_only().unwrap_or(false));
6123 assert!(cookie.secure().unwrap_or(false));
6124 }
6125
6126 #[test]
6128 fn the_removal_matches_the_cookie_it_clears() {
6129 let removal = remember_removal("/admin");
6130 assert_eq!(removal.name(), REMEMBER_COOKIE);
6131 assert_eq!(removal.path(), Some("/admin"));
6132 }
6133}