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;
32pub(crate) mod keyed;
33pub mod list;
34pub mod medialib;
35pub mod nav;
36pub(crate) mod palette;
37pub mod persist;
38pub mod picker;
39pub mod plugins;
40pub mod relation;
41pub(crate) mod requestlog;
42mod richtext;
43mod roles;
44mod routemap;
45pub mod routes;
46mod session;
47pub mod settings;
48pub mod skin;
49mod sql;
50pub mod ui;
51mod upload;
52mod users;
53pub mod widget;
54mod widgets;
55
56pub use axum;
64
65pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
66pub use error::AdminError;
67pub use session::{FlashLevel, SessionHandle};
68pub use upload::VerifiedUpload;
69
70use std::collections::HashMap;
71use std::sync::{Arc, RwLock};
72
73use askama::Template;
74use axum::body::Body;
75use axum::extract::{Path, Query, Request, State};
76use axum::http::{header, HeaderMap};
77use axum::middleware::{self, Next};
78use axum::response::{Html, IntoResponse, Redirect, Response};
79use axum::routing::{any, get, post};
80use axum::{Extension, Form, Router};
81use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
82use chrono_tz::{Tz, TZ_VARIANTS};
83use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
84use laterite_core::{t, CatalogStore, Db, Text, Translator};
85use serde::{Deserialize, Serialize};
86
87pub trait AdminRegistry {
93 fn add_resource(&mut self, resource: Resource);
95 fn add_permission(&mut self, permission: Permission);
97 fn add_settings(&mut self, item: settings::SettingsItem);
99 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
101 fn add_persister(&mut self, persister: persist::PersisterReg);
103 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
105 fn add_screen(&mut self, screen: routes::ScreenReg);
108 fn add_public_route(&mut self, route: routes::PublicRouteReg);
111 fn add_nav(&mut self, item: nav::NavItem);
113 fn add_resource_hook(&mut self, hook: ResourceHook);
115 fn add_editor_command(&mut self, command: editor::EditorCommand);
117 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>);
119 fn add_widget(&mut self, widget: widget::WidgetReg);
121 fn add_skin(&mut self, skin: skin::Skin);
123 fn add_asset(&mut self, key: &'static str, asset: AdminAsset);
127}
128
129impl AdminRegistry for laterite_core::Registry {
130 fn add_resource(&mut self, resource: Resource) {
131 self.add(resource);
132 }
133 fn add_permission(&mut self, permission: Permission) {
134 self.add(permission);
135 }
136 fn add_settings(&mut self, item: settings::SettingsItem) {
137 self.add(item);
138 }
139 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
140 self.add(source);
141 }
142 fn add_screen(&mut self, screen: routes::ScreenReg) {
143 self.add(screen);
144 }
145 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
146 self.add(route);
147 }
148 fn add_nav(&mut self, item: nav::NavItem) {
149 self.add(item);
150 }
151 fn add_resource_hook(&mut self, hook: ResourceHook) {
152 self.add(hook);
153 }
154 fn add_editor_command(&mut self, command: editor::EditorCommand) {
155 self.add(command);
156 }
157 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>) {
158 self.add(editor::SnippetSourceReg(source));
159 }
160 fn add_widget(&mut self, widget: widget::WidgetReg) {
161 self.add(widget);
162 }
163 fn add_skin(&mut self, skin: skin::Skin) {
164 self.add(skin);
165 }
166 fn add_asset(&mut self, key: &'static str, asset: AdminAsset) {
167 self.add(AssetReg { key, asset });
168 }
169 fn add_persister(&mut self, persister: persist::PersisterReg) {
170 self.add(persister);
171 }
172 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
173 self.add(field_type);
174 }
175}
176
177#[derive(Clone)]
184pub struct ResourceHook {
185 pub target: String,
186 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
187}
188
189impl ResourceHook {
190 pub fn new(
191 target: impl Into<String>,
192 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
193 ) -> Self {
194 Self {
195 target: target.into(),
196 apply: Arc::new(apply),
197 }
198 }
199
200 pub fn apply(&self, resource: Resource) -> Resource {
202 (self.apply)(resource)
203 }
204}
205
206impl std::fmt::Debug for ResourceHook {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 write!(f, "ResourceHook({})", self.target)
209 }
210}
211
212pub(crate) const SESSION_COOKIE: &str = "laterite_session";
213const REMEMBER_COOKIE: &str = "laterite_remember";
217
218pub(crate) const ICON_SPRITE: &str = "icons.svg";
221
222#[derive(Clone)]
224pub(crate) struct AdminState {
225 auth: AuthService,
226 db: Db,
227 parameters: laterite_core::parameters::Parameters,
229 upload_extensions: Arc<Vec<String>>,
231 nav: Arc<nav::NavTree>,
232 nav_items: Arc<Vec<nav::NavItem>>,
235 settings: Arc<Vec<settings::SettingsItem>>,
236 permissions: Arc<Vec<Permission>>,
237 admin_path: Arc<str>,
241 cookie_path: Arc<str>,
245 storage_dir: Arc<str>,
247 signing_key: Arc<Vec<u8>>,
249 order: std::collections::BTreeMap<String, Vec<String>>,
251 secure_cookie: bool,
252 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
254 icons: Arc<laterite_core::icons::IconSet>,
256 sprite_url: Arc<str>,
258 origin: Arc<str>,
260 timezone: Tz,
261 default_locale: Arc<str>,
265 catalogs: Arc<CatalogStore>,
268 app_name: String,
271 brand_cache: Arc<RwLock<Option<Arc<settings::brand::Brand>>>>,
275 skin: Arc<skin::Active>,
277 asset_urls: Arc<AssetUrls>,
279 field_types: Arc<field::FieldRegistry>,
282 column_types: Arc<list::ColumnRegistry>,
285 widgets: Arc<Vec<widget::WidgetReg>>,
288 plugin_defined: Arc<laterite_core::Registry>,
291 assets: Arc<AssetRegistry>,
293 pickers: Arc<picker::PickerRegistry>,
296 palette: Arc<Vec<palette::RecordSource>>,
299 overrides: Arc<dyn field::OverrideResolver>,
303}
304
305impl AdminState {
306 #[cfg(test)]
307 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
308 Self {
309 auth,
310 parameters: laterite_core::parameters::Parameters::new(db.clone()),
311 upload_extensions: Arc::new(upload_extensions(&[])),
312 db,
313 nav: Arc::new(nav::NavTree::default()),
314 nav_items: Arc::new(Vec::new()),
315 settings: Arc::new(Vec::new()),
316 permissions: Arc::new(builtin_permissions()),
317 trusted_proxies: Arc::new(Vec::new()),
318 icons: Arc::new(laterite_core::icons::IconSet::new()),
319 sprite_url: Arc::from(""),
320 admin_path: Arc::from("/admin"),
321 cookie_path: Arc::from("/admin"),
322 storage_dir: Arc::from("storage"),
323 signing_key: Arc::new(random_key()),
324 order: std::collections::BTreeMap::new(),
325 secure_cookie: false,
326 origin: Arc::from(""),
327 timezone: Tz::UTC,
328 default_locale: Arc::from("en"),
329 catalogs: Arc::new(CatalogStore::default()),
330 app_name: "Laterite".to_string(),
331 brand_cache: Arc::new(RwLock::new(None)),
332 skin: Arc::new(skin::Active::default()),
333 field_types: Arc::new(field::builtin_registry()),
334 column_types: Arc::new(list::builtin_column_registry()),
335 widgets: Arc::new(widgets::builtin()),
336 plugin_defined: Arc::new(laterite_core::Registry::new()),
337 assets: Arc::new(builtin_assets()),
338 asset_urls: Arc::new(asset_urls(&builtin_assets())),
339 pickers: Arc::new(picker::PickerRegistry::new()),
340 palette: Arc::new(Vec::new()),
341 overrides: Arc::new(field::NoOverrides),
342 }
343 }
344
345 async fn brand(&self) -> Arc<settings::brand::Brand> {
349 {
350 let cached = self.brand_cache.read().unwrap().clone();
351 if let Some(brand) = cached {
352 return brand;
353 }
354 }
355 let resolved = Arc::new(settings::brand::Brand::resolve(&self.db, &self.app_name).await);
356 *self.brand_cache.write().unwrap() = Some(Arc::clone(&resolved));
357 resolved
358 }
359
360 fn invalidate_brand(&self) {
362 *self.brand_cache.write().unwrap() = None;
363 }
364}
365
366#[derive(Clone)]
372#[non_exhaustive]
373pub struct AdminConfig {
374 pub secure_cookie: bool,
377 pub trusted_proxies: Vec<String>,
381 pub timezone: String,
385 pub locale: String,
389 pub app_name: String,
393 pub path: String,
397 pub origin: String,
402 pub storage_dir: String,
407 pub skin: String,
410 pub upload_extensions: Vec<String>,
413 pub order: std::collections::BTreeMap<String, Vec<String>>,
416 pub menu: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
418 pub secret: Vec<u8>,
422}
423
424impl Default for AdminConfig {
425 fn default() -> Self {
426 Self {
427 secure_cookie: false,
428 trusted_proxies: Vec::new(),
429 timezone: "UTC".to_string(),
430 locale: "en".to_string(),
431 app_name: "Laterite".to_string(),
432 path: "/admin".to_string(),
433 origin: String::new(),
434 storage_dir: "storage".to_string(),
435 skin: "standard".to_string(),
436 upload_extensions: Vec::new(),
437 order: std::collections::BTreeMap::new(),
438 menu: std::collections::BTreeMap::new(),
439 secret: random_key(),
440 }
441 }
442}
443
444pub(crate) fn random_key() -> Vec<u8> {
446 use rand::RngCore;
447 let mut key = vec![0u8; 32];
448 rand::thread_rng().fill_bytes(&mut key);
449 key
450}
451
452#[derive(Clone)]
461pub struct Shell {
462 pub(crate) base: String,
466 brand: String,
470 brand_look: Arc<settings::brand::Brand>,
472 skin: Arc<skin::Active>,
473 nav: Vec<NavView>,
474 full_name: String,
475 initial: String,
476 tz: Tz,
480 i18n: Translator,
484 sidebar: Vec<settings::CategoryView>,
488 crumbs: Vec<CrumbView>,
491 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
493 pub(crate) sprite_url: Arc<str>,
496 site_url: String,
500 asset_urls: Arc<AssetUrls>,
504 pub(crate) csrf_token: String,
508 pub(crate) flash: Vec<FlashLine>,
512 pub(crate) assets: Vec<PageAsset>,
516}
517
518#[derive(Template)]
520#[template(path = "page.html")]
521struct PageTemplate {
522 title: String,
523 back: Option<(String, String)>,
525 actions: Option<html::Markup>,
527 aside: Option<(String, html::Markup)>,
529 body: html::Markup,
530 shell: Shell,
531}
532
533pub struct PageHead {
544 title: Text,
545 back: Option<(Text, String)>,
546 actions: Option<html::Markup>,
547 aside: Option<(Text, html::Markup)>,
548 assets: Vec<&'static str>,
549}
550
551impl PageHead {
552 pub fn new(title: impl Into<Text>) -> Self {
553 Self {
554 title: title.into(),
555 back: None,
556 actions: None,
557 aside: None,
558 assets: Vec::new(),
559 }
560 }
561
562 pub fn assets(mut self, keys: &[&'static str]) -> Self {
566 for key in keys {
567 if !self.assets.contains(key) {
568 self.assets.push(key);
569 }
570 }
571 self
572 }
573
574 pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
576 self.back = Some((label.into(), href.into()));
577 self
578 }
579
580 pub fn actions(mut self, actions: html::Markup) -> Self {
583 self.actions = Some(actions);
584 self
585 }
586
587 pub fn aside(mut self, label: impl Into<Text>, panel: html::Markup) -> Self {
591 self.aside = Some((label.into(), panel));
592 self
593 }
594}
595
596#[derive(Clone)]
598pub(crate) struct PageAsset {
599 pub(crate) url: String,
600 pub(crate) css: bool,
601}
602
603#[derive(Clone)]
606pub(crate) struct FlashLine {
607 pub(crate) level: session::FlashLevel,
608 pub(crate) text: String,
609 pub(crate) sticky: bool,
611}
612
613impl Shell {
614 #[allow(clippy::too_many_arguments)]
615 fn new(
616 base: &str,
617 brand: Arc<settings::brand::Brand>,
618 skin: Arc<skin::Active>,
619 nav: &[nav::NavNode],
620 user: &AuthenticatedUser,
621 default_tz: Tz,
622 sidebar: Vec<settings::CategoryView>,
623 active_nav: Option<&str>,
624 csrf_token: String,
625 flash: Vec<session::Flash>,
626 i18n: Translator,
627 asset_urls: Arc<AssetUrls>,
628 site_url: String,
629 icon_set: Arc<laterite_core::icons::IconSet>,
630 sprite_url: Arc<str>,
631 ) -> Self {
632 let full_name = user.user.full_name();
633 let initial = full_name
634 .chars()
635 .next()
636 .map(|c| c.to_uppercase().to_string())
637 .unwrap_or_else(|| "?".to_string());
638 let flash = flash
641 .into_iter()
642 .map(|f| FlashLine {
643 sticky: f.sticky,
644 level: f.level,
645 text: i18n.t(&f.text),
646 })
647 .collect();
648 let nav: Vec<NavView> = nav
649 .iter()
650 .map(|n| NavView {
651 key: n.item.key.clone(),
652 label: i18n.t(&n.item.label),
653 path: nav::tab_path(n).unwrap_or_default().to_string(),
654 active: active_nav == Some(n.item.key.as_str()),
655 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
656 })
657 .collect();
658 let mut crumbs: Vec<CrumbView> = nav
659 .iter()
660 .filter(|n: &&NavView| n.active)
661 .map(|n| CrumbView {
662 label: n.label.clone(),
663 href: n.path.clone(),
664 })
665 .collect();
666 if let Some(entry) = sidebar
667 .iter()
668 .flat_map(|g| g.items.iter())
669 .find(|i| i.active)
670 {
671 crumbs.push(CrumbView {
672 label: entry.label.clone(),
673 href: entry.path.clone(),
674 });
675 }
676 Shell {
677 asset_urls,
678 icon_set,
679 sprite_url,
680 site_url,
681 base: base.to_string(),
682 brand: brand.name.clone(),
683 brand_look: brand,
684 skin,
685 nav,
686 crumbs,
687 full_name,
688 initial,
689 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
690 i18n,
691 sidebar,
692 csrf_token,
693 flash,
694 assets: Vec::new(),
695 }
696 }
697
698 pub(crate) fn site_url(&self) -> &str {
700 &self.site_url
701 }
702
703 pub(crate) fn sidebar_anchored(&self) -> bool {
706 self.nav.iter().any(|n| n.active)
707 }
708
709 pub fn icon(&self, name: &str) -> String {
713 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
714 }
715
716 pub(crate) fn asset(&self, key: &str) -> String {
721 match self.asset_urls.get(key) {
722 Some(path) => format!("{}/assets/{path}", self.base),
723 None => {
724 debug_assert!(false, "asset key `{key}` is not registered");
725 format!("{}/assets/{key}", self.base)
726 }
727 }
728 }
729
730 pub fn t(&self, source: &str) -> String {
733 self.i18n.t(&Text::dynamic(source))
734 }
735
736 pub fn tt(&self, text: &Text) -> String {
739 self.i18n.t(text)
740 }
741
742 pub fn i18n(&self) -> &Translator {
745 &self.i18n
746 }
747
748 pub fn locale(&self) -> &str {
750 self.i18n.locale()
751 }
752
753 pub fn base(&self) -> &str {
755 &self.base
756 }
757
758 pub fn csrf_token(&self) -> &str {
762 &self.csrf_token
763 }
764
765 pub fn brand_logo(&self) -> Option<&str> {
767 self.brand_look.logo_url.as_deref()
768 }
769 pub fn brand_favicon(&self) -> Option<&str> {
771 self.brand_look.favicon_url.as_deref()
772 }
773 pub fn brand_css(&self) -> &str {
775 &self.brand_look.css
776 }
777 pub fn skin_code(&self) -> &str {
780 &self.skin.code
781 }
782
783 pub fn skin_css(&self) -> Option<&str> {
785 self.skin.stylesheet.as_deref()
786 }
787
788 pub fn mode_default(&self) -> &str {
789 &self.brand_look.mode
790 }
791
792 pub fn datetime(&self, utc: &str) -> String {
795 match chrono::DateTime::parse_from_rfc3339(utc) {
796 Ok(at) => at
797 .with_timezone(&self.tz)
798 .format("%Y-%m-%d %H:%M")
799 .to_string(),
800 Err(_) => utc.to_string(),
801 }
802 }
803
804 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
808 self.page_with(PageHead::new(title.clone()), body)
809 }
810
811 pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
814 let mut shell = self.clone();
815 for key in &head.assets {
816 let url = shell.asset(key);
817 if !shell.assets.iter().any(|a| a.url == url) {
818 shell.assets.push(PageAsset {
819 url,
820 css: key.ends_with(".css"),
821 });
822 }
823 }
824 render(PageTemplate {
825 title: self.tt(&head.title),
826 back: head.back.map(|(label, href)| (self.tt(&label), href)),
827 actions: head.actions,
828 aside: head.aside.map(|(label, panel)| (self.tt(&label), panel)),
829 body,
830 shell,
831 })
832 }
833
834 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
838 let mut text = Text::dynamic(source);
839 for (name, value) in args {
840 text = text.arg(*name, *value);
841 }
842 self.i18n.t(&text)
843 }
844
845 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
848 let mut text = Text::dynamic(source);
849 for (name, value) in args {
850 text = text.arg(*name, *value);
851 }
852 self.i18n.t(&text)
853 }
854
855 #[cfg(test)]
856 pub(crate) fn test() -> Self {
857 Shell {
858 asset_urls: Arc::new(asset_urls(&builtin_assets())),
859 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
860 sprite_url: Arc::from("/admin/assets/icons.svg"),
861 site_url: "http://localhost".to_string(),
862 base: "/admin".to_string(),
863 brand: "Laterite".to_string(),
864 brand_look: Arc::new(settings::brand::Brand::of_name("Laterite")),
865 skin: Arc::new(skin::Active::default()),
866 nav: Vec::new(),
867 full_name: "Test Operator".to_string(),
868 initial: "T".to_string(),
869 tz: Tz::UTC,
870 i18n: Translator::new("en"),
871 sidebar: Vec::new(),
872 crumbs: Vec::new(),
873 csrf_token: "test-csrf-token".to_string(),
874 flash: Vec::new(),
875 assets: Vec::new(),
876 }
877 }
878}
879
880fn settings_context(
887 visible: &[settings::SettingsItem],
888 admin_path: &str,
889 path: &str,
890) -> (bool, Option<String>) {
891 if path == format!("{admin_path}/settings") {
892 return (true, None);
893 }
894 let own = visible
900 .iter()
901 .filter(|i| i.link.is_none())
902 .find(|i| path == format!("{admin_path}{}", i.route()))
903 .map(|i| i.code.clone());
904 if own.is_some() {
905 return (true, own);
906 }
907 let linked = visible
908 .iter()
909 .filter_map(|i| {
910 i.link
911 .as_deref()
912 .map(|link| (format!("{admin_path}{link}"), &i.code))
913 })
914 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
915 .max_by_key(|(link, _)| link.len())
916 .map(|(_, code)| code.clone());
917 (linked.is_some(), linked)
918}
919
920#[allow(clippy::too_many_arguments)]
926#[derive(Default)]
929struct OwnMenu {
930 nav_order: Option<String>,
931 settings_order: Option<String>,
932 moves: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
933}
934
935impl OwnMenu {
936 async fn load(state: &AdminState, user_id: i64) -> Self {
937 let read = |key: String| async move {
938 laterite_auth::store::user_preference(&state.db, user_id, &key)
939 .await
940 .unwrap_or_default()
941 };
942 let nav_order = read(nav::order_preference(nav::TOP_HIERARCHY)).await;
943 let settings_order = read(nav::order_preference(settings::HIERARCHY)).await;
944 let moves = read(nav::MOVES_PREFERENCE.to_string())
945 .await
946 .map(|json| nav::operator_moves(&json))
947 .unwrap_or_default();
948 Self {
949 nav_order,
950 settings_order,
951 moves,
952 }
953 }
954}
955
956fn own_tree(
959 state: &AdminState,
960 own: &OwnMenu,
961) -> (Arc<nav::NavTree>, Arc<Vec<settings::SettingsItem>>) {
962 if own.moves.is_empty() {
963 return (Arc::clone(&state.nav), Arc::clone(&state.settings));
964 }
965 let mut items: Vec<nav::NavItem> = state.nav_items.as_ref().clone();
966 let mut settings: Vec<settings::SettingsItem> = state.settings.as_ref().clone();
967 nav::apply_moves(&mut items, &mut settings, &own.moves);
968 match nav::NavTree::build_with(items, &state.admin_path, &state.order) {
969 Ok(tree) => (Arc::new(tree), Arc::new(settings)),
970 Err(e) => {
971 tracing::warn!(error = %e, "an operator's menu moves could not be applied");
972 (Arc::clone(&state.nav), Arc::clone(&state.settings))
973 }
974 }
975}
976
977#[allow(clippy::too_many_arguments)]
978fn resolve_nav_context(
979 nav: &nav::NavTree,
980 items: &[settings::SettingsItem],
981 admin_path: &str,
982 perms: &PermissionSet,
983 own_order: Option<&str>,
984 path: &str,
985 tr: &Translator,
986 icons: icons::Icons<'_>,
987 order: &std::collections::BTreeMap<String, Vec<String>>,
988 own_settings_order: Option<&str>,
989) -> (
990 Vec<nav::NavNode>,
991 Vec<settings::CategoryView>,
992 Option<String>,
993) {
994 let mut visible_nav = nav.visible(&|code| perms.allows(code));
995 if let Some(order) = own_order {
996 nav::apply_order(&mut visible_nav, order);
997 }
998 let own_sequence = own_settings_order.map(laterite_core::arrange::parse_sequence);
999 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
1001 .into_iter()
1002 .filter(|i| !i.per_operator)
1003 .collect();
1004 let (in_context, active) = settings_context(&visible, admin_path, path);
1005 if in_context {
1006 let sidebar = settings::sidebar_groups(
1007 &visible,
1008 admin_path,
1009 active.as_deref(),
1010 tr,
1011 icons,
1012 order.get(settings::HIERARCHY).map(Vec::as_slice),
1013 own_sequence.as_deref(),
1014 );
1015 return (visible_nav, sidebar, Some("settings".to_string()));
1016 }
1017 let active_top = nav::active_top(&visible_nav, admin_path, path);
1018 let sidebar = active_top
1019 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
1020 .unwrap_or_default();
1021 let active_key = active_top.map(|node| node.item.key.clone());
1022 (visible_nav, sidebar, active_key)
1023}
1024
1025fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
1029 preference
1030 .and_then(|name| name.parse::<Tz>().ok())
1031 .unwrap_or(default_tz)
1032}
1033
1034fn zone_offered(name: &str) -> bool {
1036 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
1037}
1038
1039fn base_lang(tag: &str) -> String {
1042 tag.split(['-', '_'])
1043 .next()
1044 .unwrap_or(tag)
1045 .to_ascii_lowercase()
1046}
1047
1048fn is_serveable(base: &str, serveable: &[String]) -> bool {
1054 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
1055}
1056
1057fn default_locale(configured: &str, serveable: &[String]) -> String {
1060 let base = base_lang(configured);
1061 if is_serveable(&base, serveable) {
1062 base
1063 } else {
1064 "en".to_string()
1065 }
1066}
1067
1068fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
1072 let mut out = vec!["en".to_string()];
1073 out.extend(catalogs.locales());
1074 out
1075}
1076
1077fn locale_name(code: &str) -> String {
1082 if code == "en" {
1083 "English".to_string()
1084 } else {
1085 code.to_string()
1086 }
1087}
1088
1089fn parse_accept_language(header: &str) -> Vec<String> {
1092 let mut items: Vec<(f32, String)> = header
1093 .split(',')
1094 .filter_map(|part| {
1095 let mut bits = part.split(';');
1096 let tag = bits.next()?.trim();
1097 if tag.is_empty() || tag == "*" {
1098 return None;
1099 }
1100 let q = bits
1101 .find_map(|p| p.trim().strip_prefix("q="))
1102 .and_then(|q| q.parse::<f32>().ok())
1103 .unwrap_or(1.0);
1104 Some((q, tag.to_string()))
1105 })
1106 .collect();
1107 items.sort_by(|a, b| b.0.total_cmp(&a.0));
1109 items.into_iter().map(|(_, tag)| tag).collect()
1110}
1111
1112fn resolve_locale_chain(
1118 preference: Option<&str>,
1119 accept_language: Option<&str>,
1120 default: &str,
1121 serveable: &[String],
1122) -> Vec<String> {
1123 let mut chain: Vec<String> = Vec::new();
1124 let consider = |tag: &str, chain: &mut Vec<String>| {
1125 let base = base_lang(tag);
1126 if is_serveable(&base, serveable) && !chain.contains(&base) {
1127 chain.push(base);
1128 }
1129 };
1130 if let Some(pref) = preference {
1131 consider(pref, &mut chain);
1132 }
1133 if let Some(header) = accept_language {
1134 for tag in parse_accept_language(header) {
1135 consider(&tag, &mut chain);
1136 }
1137 }
1138 consider(default, &mut chain);
1139 consider("en", &mut chain);
1142 chain
1143}
1144
1145fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
1149 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
1150 let serveable = state.catalogs.locales();
1151 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
1152 Translator::with_chain(chain, state.catalogs.clone())
1153}
1154
1155#[derive(Serialize)]
1163#[non_exhaustive]
1164pub struct Resource {
1165 pub base_path: String,
1171 pub nav_label: Text,
1173 pub list: list::ListConfig,
1174 pub form: Option<form::FormConfig>,
1175 pub permission: Option<String>,
1182 pub import: Option<ImportConfig>,
1185 pub palette: Option<String>,
1188 pub relations: Vec<relation::RelationConfig>,
1191 pub menu: nav::Placement,
1194}
1195
1196#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1199#[non_exhaustive]
1200pub struct ImportConfig {
1201 #[serde(default)]
1204 pub permission: Option<String>,
1205}
1206
1207impl ImportConfig {
1208 pub fn new() -> Self {
1209 Self::default()
1210 }
1211
1212 pub fn permission(mut self, code: impl Into<String>) -> Self {
1215 self.permission = Some(code.into());
1216 self
1217 }
1218}
1219
1220#[derive(Clone, Serialize)]
1225#[non_exhaustive]
1226pub struct Permission {
1227 pub code: String,
1228 pub label: Text,
1230 pub group: Text,
1231 pub default_roles: Vec<String>,
1234 pub renamed_from: Vec<String>,
1237}
1238
1239pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
1240
1241impl Permission {
1242 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
1245 Self {
1246 code: code.into(),
1247 label: label.into(),
1248 group: group.into(),
1249 default_roles: Vec::new(),
1250 renamed_from: Vec::new(),
1251 }
1252 }
1253
1254 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
1260 where
1261 I: IntoIterator<Item = S>,
1262 S: Into<String>,
1263 {
1264 self.renamed_from = codes.into_iter().map(Into::into).collect();
1265 self
1266 }
1267
1268 pub fn roles<I, S>(mut self, roles: I) -> Self
1271 where
1272 I: IntoIterator<Item = S>,
1273 S: Into<String>,
1274 {
1275 self.default_roles = roles.into_iter().map(Into::into).collect();
1276 self
1277 }
1278}
1279
1280pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1286 let permissions: Vec<Permission> = builtin_permissions()
1287 .into_iter()
1288 .chain(contributed.iter().cloned())
1289 .collect();
1290 let mut out: Vec<(String, String)> = Vec::new();
1291 for permission in &permissions {
1292 for old in &permission.renamed_from {
1293 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1294 panic!(
1295 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1296 permission.code
1297 );
1298 }
1299 if permissions.iter().any(|p| &p.code == old) {
1300 panic!(
1301 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1302 permission.code
1303 );
1304 }
1305 out.push((old.clone(), permission.code.clone()));
1306 }
1307 }
1308 out
1309}
1310
1311pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1317 let permissions: Vec<Permission> = builtin_permissions()
1320 .into_iter()
1321 .chain(contributed.iter().cloned())
1322 .collect();
1323 let held = |role: &str| -> Vec<String> {
1324 permissions
1325 .iter()
1326 .filter(|p| {
1327 if p.default_roles.is_empty() {
1328 role == ROLE_ADMIN
1329 } else {
1330 p.default_roles.iter().any(|r| r == role)
1331 }
1332 })
1333 .map(|p| p.code.clone())
1334 .collect()
1335 };
1336 vec![
1337 laterite_auth::store::SystemRole {
1338 code: ROLE_ADMIN,
1339 name: "Administrator",
1340 description: "Administers the panel itself: operators, roles and settings.",
1341 permissions: held(ROLE_ADMIN),
1342 },
1343 laterite_auth::store::SystemRole {
1344 code: ROLE_EDITOR,
1345 name: "Editor",
1346 description: "Works with the content the application declares.",
1347 permissions: held(ROLE_EDITOR),
1348 },
1349 ]
1350}
1351
1352impl Resource {
1353 pub fn new(
1358 base_path: impl Into<String>,
1359 nav_label: impl Into<Text>,
1360 list: list::ListConfig,
1361 ) -> Self {
1362 Self {
1363 base_path: base_path.into(),
1364 nav_label: nav_label.into(),
1365 list,
1366 form: None,
1367 permission: None,
1368 import: None,
1369 palette: None,
1370 relations: Vec::new(),
1371 menu: nav::Placement::default(),
1372 }
1373 }
1374
1375 pub fn palette(mut self, source: impl Into<String>) -> Self {
1378 self.palette = Some(source.into());
1379 self
1380 }
1381
1382 pub fn menu(mut self, placement: nav::Placement) -> Self {
1384 self.menu = placement;
1385 self
1386 }
1387
1388 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1390 self.relations.push(relation);
1391 self
1392 }
1393
1394 pub fn import(mut self, import: ImportConfig) -> Self {
1396 self.import = Some(import);
1397 self
1398 }
1399
1400 pub fn form(mut self, form: form::FormConfig) -> Self {
1403 self.form = Some(form);
1404 self
1405 }
1406
1407 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1409 self.permission = Some(permission.into());
1410 self
1411 }
1412}
1413
1414struct SystemRoleGuard;
1420
1421#[laterite_core::strata::async_trait]
1422impl laterite_core::ModelListener for SystemRoleGuard {
1423 async fn before_delete(
1424 &self,
1425 cx: &mut laterite_core::SaveCx<'_>,
1426 rec: &laterite_core::Record,
1427 ) -> Result<(), Text> {
1428 let Some(id) = rec.id() else {
1429 return Ok(());
1430 };
1431 let backend = cx.backend();
1433 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1434 Ok(true) => Err(t!(
1435 "The framework maintains this role; it cannot be deleted."
1436 )),
1437 Err(_) => Err(t!(
1439 "Could not check whether this role is maintained by the framework."
1440 )),
1441 Ok(false) => Ok(()),
1442 }
1443 }
1444}
1445
1446fn builtin_permissions() -> Vec<Permission> {
1449 let mut out = vec![
1452 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1453 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1454 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1455 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1456 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1457 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1458 Permission::new(
1459 "backend.view_request_log",
1460 "View the request log",
1461 "Backend",
1462 ),
1463 ];
1464 out.extend(medialib::permissions());
1465 out
1466}
1467
1468pub fn descriptor_sources() -> Vec<String> {
1474 let mut out = Vec::new();
1475 out.extend(laterite_core::collect_sources(&builtin_resources()));
1476 out.extend(laterite_core::collect_sources(&builtin_settings()));
1477 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1478 out.sort();
1479 out.dedup();
1480 out
1481}
1482
1483pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1498 builtin_modules().iter().map(|m| m.migrations()).collect()
1499}
1500
1501pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1505 vec![
1506 Box::new(laterite_auth::AuthModule),
1507 Box::new(settings::SettingsModule),
1508 Box::new(laterite_core::parameters::ParametersModule),
1509 Box::new(requestlog::RequestLogModule),
1510 Box::new(plugins::PluginsModule),
1511 Box::new(laterite_media::MediaModule),
1512 Box::new(medialib::MediaLibraryModule),
1513 Box::new(import::ImportModule),
1514 ]
1515}
1516
1517#[derive(Default)]
1541pub struct Contributions {
1542 pub resources: Vec<Resource>,
1544 pub settings: Vec<settings::SettingsItem>,
1546 pub permissions: Vec<Permission>,
1548 pub picker_sources: Vec<picker::PickerSourceReg>,
1550 pub persisters: Vec<persist::PersisterReg>,
1552 pub listeners: Vec<laterite_core::ModelListenerReg>,
1554 pub field_types: Vec<field::FieldTypeReg>,
1558 pub column_types: Vec<list::ColumnTypeReg>,
1559 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1562 pub screens: Vec<routes::ScreenReg>,
1564 pub public_routes: Vec<routes::PublicRouteReg>,
1566 pub nav: Vec<nav::NavItem>,
1569 pub resource_hooks: Vec<ResourceHook>,
1572 pub widgets: Vec<widget::WidgetReg>,
1574 pub skins: Vec<skin::Skin>,
1576 pub assets: Vec<AssetReg>,
1578 pub plugin_defined: Arc<laterite_core::Registry>,
1583}
1584
1585pub fn router(
1586 auth: AuthService,
1587 db: Db,
1588 contributions: Contributions,
1589 config: AdminConfig,
1590 catalogs: Arc<CatalogStore>,
1591) -> Router {
1592 let Contributions {
1593 resources: app_resources,
1594 settings: app_settings,
1595 permissions: app_permissions,
1596 picker_sources: app_picker_sources,
1597 persisters: app_persisters,
1598 listeners: app_listeners,
1599 field_types: app_field_types,
1600 column_types: app_column_types,
1601 widgets: app_widgets,
1602 skins: app_skins,
1603 assets: app_assets,
1604 icons: app_icons,
1605 screens: app_screens,
1606 public_routes: app_public,
1607 nav: app_nav,
1608 resource_hooks,
1609 plugin_defined,
1610 } = contributions;
1611 let mut app_screens = app_screens;
1614 app_screens.push(medialib::screen());
1615 let mut app_public = app_public;
1616 app_public.push(medialib::serve_route());
1617 let admin_path = normalize_path(&config.path);
1618
1619 let mut resources = builtin_resources();
1620 let mut app_resources = app_resources;
1621 for hook in resource_hooks {
1624 let found = resources
1625 .iter_mut()
1626 .chain(app_resources.iter_mut())
1627 .find(|r| r.base_path == hook.target);
1628 match found {
1629 Some(resource) => {
1630 let changed = hook.apply(std::mem::replace(
1631 resource,
1632 Resource::new("", "", list::ListConfig::default()),
1633 ));
1634 *resource = changed;
1635 }
1636 None => panic!(
1637 "a resource hook targets `{}`, which no module declared",
1638 hook.target
1639 ),
1640 }
1641 }
1642 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1646 prefix_resource(&admin_path, resource);
1647 }
1648 for resource in app_resources.iter_mut() {
1652 if let Some(import) = &resource.import {
1653 let permission = import
1654 .permission
1655 .clone()
1656 .or_else(|| resource.permission.clone())
1657 .unwrap_or_else(|| {
1658 panic!(
1659 "admin resource `{}` declares `import` but no permission; importing \
1660 writes records, so it must be gated",
1661 resource.base_path
1662 )
1663 });
1664 resource.list.import = Some(routes::feature_link(
1665 &resource.base_path,
1666 "import",
1667 &permission,
1668 ));
1669 }
1670 }
1671
1672 let mut settings = builtin_settings();
1673 settings.extend(app_settings);
1674 let mut app_listeners = app_listeners;
1677 app_listeners.insert(
1678 0,
1679 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1680 );
1681
1682 let mut permissions = builtin_permissions();
1683 permissions.extend(app_permissions);
1684 let permission_codes: std::collections::HashSet<String> =
1686 permissions.iter().map(|p| p.code.clone()).collect();
1687
1688 let mut nav_items = vec![
1694 nav::NavItem::new("dashboard", "Dashboard", "")
1695 .icon("layout-dashboard")
1696 .slot(laterite_core::arrange::slot::HOME),
1697 nav::NavItem::new("settings", "Settings", "/settings")
1698 .icon("settings")
1699 .slot(laterite_core::arrange::slot::SYSTEM),
1700 ];
1701 nav_items.extend(app_nav);
1702 for resource in &app_resources {
1703 if resource.menu.hidden {
1704 continue;
1705 }
1706 let unprefixed = resource
1707 .base_path
1708 .strip_prefix(admin_path.as_str())
1709 .unwrap_or(&resource.base_path)
1710 .to_string();
1711 let mut item = nav::NavItem::new(
1712 format!("resource:{unprefixed}"),
1713 resource.nav_label.clone(),
1714 unprefixed,
1715 )
1716 .slot(resource.menu.slot)
1717 .order(resource.menu.order);
1718 item.icon = resource.menu.icon.clone();
1719 item.permission = resource.permission.clone();
1720 item.under = resource.menu.under.clone();
1721 item.after = resource.menu.after.clone();
1722 item.before = resource.menu.before.clone();
1723 nav_items.push(item);
1724 }
1725 for reg in &app_screens {
1726 let Some(label) = ®.nav_label else {
1727 continue;
1728 };
1729 if reg.menu.hidden {
1730 continue;
1731 }
1732 let mut item = nav::NavItem::new(
1733 format!("screen:{}", reg.base_path),
1734 label.clone(),
1735 reg.base_path.clone(),
1736 )
1737 .permission(reg.permission.clone())
1738 .slot(reg.menu.slot)
1739 .order(reg.menu.order);
1740 item.icon = reg.menu.icon.clone();
1741 item.under = reg.menu.under.clone();
1742 item.after = reg.menu.after.clone();
1743 item.before = reg.menu.before.clone();
1744 nav_items.push(item);
1745 }
1746 nav::apply_moves(&mut nav_items, &mut settings, &config.menu);
1749 let nav = nav::NavTree::build_with(nav_items.clone(), &admin_path, &config.order)
1750 .unwrap_or_else(|e| panic!("admin menu: {e}"));
1751 nav.log_arrangement();
1752 settings::log_arrangement(
1753 &settings,
1754 config.order.get(settings::HIERARCHY).map(Vec::as_slice),
1755 );
1756 let palette_sources: Vec<palette::RecordSource> = app_resources
1759 .iter()
1760 .filter_map(|r| {
1761 r.palette.as_ref().map(|source| palette::RecordSource {
1762 label: r.nav_label.clone(),
1763 source: source.clone(),
1764 base_path: r.base_path.clone(),
1765 permission: r.permission.clone(),
1766 })
1767 })
1768 .collect();
1769 resources.extend(app_resources);
1770
1771 let app_name = if config.app_name.trim().is_empty() {
1772 "Laterite".to_string()
1773 } else {
1774 config.app_name.clone()
1775 };
1776
1777 let mut pickers = picker::PickerRegistry::new();
1780 for reg in app_picker_sources {
1781 let name = reg.name.clone();
1782 if !field::is_name(&name, true) {
1783 panic!("invalid picker source name `{name}`");
1784 }
1785 if pickers.insert(name.clone(), reg).is_some() {
1786 panic!("duplicate picker source `{name}`");
1787 }
1788 }
1789
1790 for source in &palette_sources {
1794 if !pickers.contains_key(&source.source) {
1795 panic!(
1796 "admin resource `{}` names palette source `{}`, which no module registered",
1797 source.base_path, source.source
1798 );
1799 }
1800 }
1801 let pickers = Arc::new(pickers);
1802 let mut field_types = field::builtin_registry();
1803 field_types.insert(
1804 "reference".to_string(),
1805 Arc::new(field::RefPickerField::new(pickers.clone())),
1806 );
1807
1808 let mut persisters = persist::PersisterRegistry::new();
1811 for reg in app_persisters {
1812 let name = reg.name.clone();
1813 if !field::is_name(&name, true) {
1814 panic!("invalid persister name `{name}`");
1815 }
1816 if persisters.insert(name.clone(), reg.persister).is_some() {
1817 panic!("duplicate persister `{name}`");
1818 }
1819 }
1820
1821 for reg in app_field_types {
1827 let name = reg.name().to_string();
1828 if !field::is_name(&name, true) {
1829 panic!("invalid field type name `{name}`");
1830 }
1831 if field_types.insert(name.clone(), reg.field_type).is_some() {
1832 panic!("duplicate field type `{name}`");
1833 }
1834 }
1835
1836 let mut icon_set = laterite_core::icons::IconSet::new();
1840 for (owner, reg) in app_icons {
1841 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1842 panic!("module `{owner}` contributed an invalid icon: {e}");
1843 }
1844 }
1845
1846 let mut assets = builtin_assets();
1849 assets.insert(
1850 ICON_SPRITE,
1851 AdminAsset {
1852 mime: "image/svg+xml",
1853 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1854 },
1855 );
1856 for reg in app_assets {
1860 let key = reg.key;
1861 if key.is_empty()
1862 || key.starts_with('/')
1863 || key.split('/').any(|s| s.is_empty() || s == "..")
1864 {
1865 panic!("invalid admin asset key `{key}`");
1866 }
1867 if assets.insert(key, reg.asset).is_some() {
1868 panic!("duplicate admin asset `{key}`");
1869 }
1870 }
1871 let mut skins = skin::builtin();
1872 skins.extend(app_skins);
1873 skin::check(&skins);
1874
1875 for item in &settings {
1879 icons::require(
1880 &icon_set,
1881 &format!("settings item `{}`", item.code),
1882 item.icon.as_deref(),
1883 );
1884 }
1885 for node in nav
1886 .top
1887 .iter()
1888 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1889 {
1890 icons::require(
1891 &icon_set,
1892 &format!("menu entry `{}`", node.item.key),
1893 node.item.icon.as_deref(),
1894 );
1895 }
1896
1897 let mut column_types = list::builtin_column_registry();
1901 for reg in app_column_types {
1902 let name = reg.name().to_string();
1903 if !field::is_name(&name, true) {
1904 panic!("invalid column type name `{name}`");
1905 }
1906 if column_types.insert(name.clone(), reg.column_type).is_some() {
1907 panic!("duplicate column type `{name}`");
1908 }
1909 }
1910
1911 let settings = Arc::new(settings);
1912 let asset_urls_arc = Arc::new(asset_urls(&assets));
1914 let active_skin = Arc::new(skin::resolve(
1915 &config.skin,
1916 &skins,
1917 &admin_path,
1918 &asset_urls_arc,
1919 ));
1920 let sprite_url: Arc<str> = Arc::from(
1921 asset_urls_arc
1922 .get(ICON_SPRITE)
1923 .map(|p| format!("{admin_path}/assets/{p}"))
1924 .unwrap_or_default()
1925 .as_str(),
1926 );
1927 let icons_arc = Arc::new(icon_set);
1928 let mut widget_regs = widgets::builtin();
1929 for reg in app_widgets {
1930 if let Some(at) = widget_regs.iter().position(|w| w.code() == reg.code()) {
1931 widget_regs[at] = reg;
1932 } else {
1933 widget_regs.push(reg);
1934 }
1935 }
1936 let state = AdminState {
1937 auth,
1938 parameters: laterite_core::parameters::Parameters::new(db.clone()),
1939 upload_extensions: Arc::new(upload_extensions(&config.upload_extensions)),
1940 db,
1941 nav: Arc::new(nav),
1942 nav_items: Arc::new(nav_items),
1943 settings: settings.clone(),
1944 permissions: Arc::new(permissions),
1945 admin_path: Arc::from(admin_path.as_str()),
1946 secure_cookie: config.secure_cookie,
1947 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1948 icons: icons_arc.clone(),
1949 sprite_url: sprite_url.clone(),
1950 origin: Arc::from(config.origin.trim_end_matches('/')),
1951 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1952 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1953 catalogs,
1954 app_name,
1955 brand_cache: Arc::new(RwLock::new(None)),
1956 skin: active_skin,
1957 field_types: Arc::new(field_types),
1958 column_types: Arc::new(column_types),
1959 widgets: Arc::new(widget_regs),
1960 plugin_defined,
1961 storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
1962 signing_key: Arc::new(config.secret.clone()),
1963 order: config.order.clone(),
1964 cookie_path: if app_public.iter().any(|r| r.is_site()) {
1965 Arc::from("/")
1966 } else {
1967 Arc::from(admin_path.as_str())
1968 },
1969 assets: Arc::new(assets),
1970 asset_urls: asset_urls_arc.clone(),
1971 pickers,
1972 palette: Arc::new(palette_sources),
1973 overrides: Arc::new(field::NoOverrides),
1974 };
1975
1976 let mut protected = Router::new().route(&admin_path, get(dashboard));
1977 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1981 for resource in &resources {
1982 let (mounted, prepared) = mount_resource(
1983 resource,
1984 &state.field_types,
1985 &state.column_types,
1986 &permission_codes,
1987 &persisters,
1988 &app_listeners,
1989 );
1990 protected = protected.merge(mounted);
1991 if let Some(prepared) = prepared {
1992 let key = resource
1993 .base_path
1994 .strip_prefix(admin_path.as_str())
1995 .unwrap_or(&resource.base_path);
1996 forms.insert(key.to_string(), prepared);
1997 }
1998 }
1999 let forms: form::Forms = Arc::new(forms);
2000 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
2004 .plugin_defined
2005 .items::<laterite_media::DiskReg>()
2006 .into_iter()
2007 .find(|d| d.name == import::DISK)
2008 .map(|d| d.driver.clone())
2009 .unwrap_or_else(|| {
2010 Arc::new(laterite_media::LocalDisk::new(format!(
2011 "{}/{}",
2012 config.storage_dir.trim_end_matches('/'),
2013 import::DISK
2014 )))
2015 });
2016 for resource in &resources {
2017 let Some(link) = &resource.list.import else {
2018 continue;
2019 };
2020 let key = resource
2021 .base_path
2022 .strip_prefix(admin_path.as_str())
2023 .unwrap_or(&resource.base_path)
2024 .to_string();
2025 let prepared = forms.get(&key).unwrap_or_else(|| {
2026 panic!(
2027 "admin resource `{}` declares `import` but has no form to import through",
2028 resource.base_path
2029 )
2030 });
2031 let feature = import::ImportFeature {
2032 disk: private_disk.clone(),
2033 };
2034 let base = link.href.clone();
2035 let ctx = routes::RouteCtx::new(
2036 state.db.clone(),
2037 state.auth.clone(),
2038 state.parameters.clone(),
2039 state.upload_extensions.clone(),
2040 state.field_types.clone(),
2041 state.overrides.clone(),
2042 state.signing_key.clone(),
2043 &state.storage_dir,
2044 &base,
2045 &admin_path,
2046 &state.origin,
2047 state.plugin_defined.clone(),
2048 state.auth.events().clone(),
2049 forms.clone(),
2050 );
2051 let info = routes::ResourceInfo::new(
2052 key,
2053 resource.base_path.clone(),
2054 resource.nav_label.clone(),
2055 link.permission.clone(),
2056 form::FormHandle::new(state.db.clone(), prepared.clone()),
2057 );
2058 protected = protected.merge(guard_with_permission(
2059 Router::new()
2060 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
2061 &link.permission,
2062 ));
2063 }
2064 for reg in &app_screens {
2068 let base = format!("{admin_path}{}", reg.base_path);
2069 let ctx = routes::RouteCtx::new(
2070 state.db.clone(),
2071 state.auth.clone(),
2072 state.parameters.clone(),
2073 state.upload_extensions.clone(),
2074 state.field_types.clone(),
2075 state.overrides.clone(),
2076 state.signing_key.clone(),
2077 &state.storage_dir,
2078 &base,
2079 &admin_path,
2080 &state.origin,
2081 state.plugin_defined.clone(),
2082 state.auth.events().clone(),
2083 forms.clone(),
2084 );
2085 protected = protected.merge(guard_with_permission(
2086 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
2087 ®.permission,
2088 ));
2089 }
2090
2091 protected = protected.merge(guard_with_permission(
2094 Router::new()
2095 .route(
2096 &format!("{admin_path}/roles/new"),
2097 get(roles::new_form).post(roles::create),
2098 )
2099 .route(
2100 &format!("{admin_path}/roles/{{id}}/edit"),
2101 get(roles::edit_form).post(roles::update),
2102 ),
2103 "backend.manage_roles",
2104 ));
2105 protected = protected.merge(guard_with_permission(
2108 Router::new()
2109 .route(
2110 &format!("{admin_path}/users/new"),
2111 get(users::new_form).post(users::create),
2112 )
2113 .route(
2114 &format!("{admin_path}/users/{{id}}/edit"),
2115 get(users::edit_form).post(users::update),
2116 )
2117 .route(
2118 &format!("{admin_path}/users/{{id}}/active"),
2119 post(users::set_active),
2120 )
2121 .route(
2122 &format!("{admin_path}/users/{{id}}/password"),
2123 post(users::reset_password),
2124 )
2125 .route(
2126 &format!("{admin_path}/users/{{id}}/unlock"),
2127 post(users::unlock),
2128 ),
2129 "backend.manage_users",
2130 ));
2131 protected = protected.merge(guard_with_permission(
2134 Router::new()
2135 .route(&format!("{admin_path}/plugins"), get(plugins::index))
2136 .route(
2137 &format!("{admin_path}/plugins/toggle"),
2138 post(plugins::toggle),
2139 ),
2140 plugins::MANAGE_PERMISSION,
2141 ));
2142 protected = protected
2143 .route(&format!("{admin_path}/settings"), get(settings_index))
2144 .route(
2145 &format!("{admin_path}/preferences"),
2146 get(preferences_form).post(preferences_update),
2147 )
2148 .route(
2149 &format!("{admin_path}/preferences/password"),
2150 post(password_update),
2151 )
2152 .route(
2153 &format!("{admin_path}/preferences/menu"),
2154 post(menu_order_update),
2155 )
2156 .route(
2157 &format!("{admin_path}/preferences/menu/moves"),
2158 post(menu_moves_update),
2159 )
2160 .route(
2161 &format!("{admin_path}/markdown/preview"),
2162 post(markdown_preview),
2163 )
2164 .route(&format!("{admin_path}/editor/menu"), get(editor_menu))
2165 .route(
2166 &format!("{admin_path}/dashboard/widget/{{code}}"),
2167 get(dashboard_widget),
2168 )
2169 .route(
2170 &format!("{admin_path}/dashboard/layout"),
2171 post(dashboard_layout_update),
2172 )
2173 .route(
2174 &format!("{admin_path}/dashboard/layout/reset"),
2175 post(dashboard_layout_reset),
2176 )
2177 .route(&format!("{admin_path}/palette"), get(palette::palette))
2178 .route(
2179 &format!("{admin_path}/preferences/sessions/revoke"),
2180 post(session_revoke),
2181 )
2182 .route(
2183 &format!("{admin_path}/preferences/sessions/revoke-others"),
2184 post(session_revoke_others),
2185 )
2186 .route(
2189 &format!("{admin_path}/pickers/{{source}}/search"),
2190 any(picker::search),
2191 )
2192 .route(
2193 &format!("{admin_path}/pickers/{{source}}/resolve"),
2194 any(picker::resolve),
2195 )
2196 .route(&format!("{admin_path}/logout"), post(logout));
2197
2198 for item in settings.iter().filter(|i| i.link.is_none()) {
2202 let path = format!("{admin_path}{}", item.route());
2203 let code = item.code.clone();
2204 let edit_code = code.clone();
2205 protected = protected.route(
2206 &path,
2207 get(
2208 move |State(state): State<AdminState>,
2209 Extension(shell): Extension<Shell>,
2210 Extension(user): Extension<AuthenticatedUser>| {
2211 let code = edit_code.clone();
2212 async move { settings_edit(state, shell, user, code).await }
2213 },
2214 )
2215 .post(
2216 move |State(state): State<AdminState>,
2217 Extension(shell): Extension<Shell>,
2218 Extension(user): Extension<AuthenticatedUser>,
2219 Extension(session): Extension<session::SessionHandle>,
2220 Form(data): Form<HashMap<String, String>>| {
2221 let code = code.clone();
2222 async move { settings_update(state, shell, user, session, code, data).await }
2223 },
2224 ),
2225 );
2226 }
2227
2228 let app = protected
2229 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
2230 .route(
2234 &format!("{admin_path}/login"),
2235 get(login_form).post(login_submit),
2236 )
2237 .route(
2238 &format!("{admin_path}/setup"),
2239 get(setup_form).post(setup_submit),
2240 )
2241 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
2242 .merge(mount_public(
2246 &app_public,
2247 &state.db,
2248 &state.auth,
2249 &state.parameters,
2250 &state.upload_extensions,
2251 &state.field_types,
2252 &state.overrides,
2253 &state.signing_key,
2254 &state.storage_dir,
2255 &admin_path,
2256 &state.origin,
2257 &state.plugin_defined,
2258 state.auth.events(),
2259 &forms,
2260 ));
2261 let app = match app_public.iter().find(|r| r.is_site()) {
2264 Some(site) => {
2265 let ctx = routes::RouteCtx::new(
2266 state.db.clone(),
2267 state.auth.clone(),
2268 state.parameters.clone(),
2269 state.upload_extensions.clone(),
2270 state.field_types.clone(),
2271 state.overrides.clone(),
2272 state.signing_key.clone(),
2273 &state.storage_dir,
2274 "/",
2275 &admin_path,
2276 &state.origin,
2277 state.plugin_defined.clone(),
2278 state.auth.events().clone(),
2279 forms.clone(),
2280 );
2281 app.fallback_service(site.route.mount(&ctx))
2282 }
2283 None => app.fallback(not_found_fallback),
2284 };
2285 let secure = state.secure_cookie;
2286 app
2287 .layer(middleware::from_fn_with_state(
2290 state.clone(),
2291 enforce_origin,
2292 ))
2293 .layer(middleware::from_fn_with_state(
2296 state.clone(),
2297 capture_client,
2298 ))
2299 .layer(
2304 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2305 header::CACHE_CONTROL,
2306 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
2307 ),
2308 )
2309 .layer(
2313 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2314 header::VARY,
2315 axum::http::HeaderValue::from_static("HX-Request"),
2316 ),
2317 )
2318 .layer(
2324 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2325 header::CONTENT_SECURITY_POLICY,
2326 axum::http::HeaderValue::from_static(CONTENT_SECURITY_POLICY),
2327 ),
2328 )
2329 .layer(
2330 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2331 header::X_CONTENT_TYPE_OPTIONS,
2332 axum::http::HeaderValue::from_static("nosniff"),
2333 ),
2334 )
2335 .layer(
2336 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2337 header::X_FRAME_OPTIONS,
2338 axum::http::HeaderValue::from_static("SAMEORIGIN"),
2339 ),
2340 )
2341 .layer(
2342 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2343 header::REFERRER_POLICY,
2344 axum::http::HeaderValue::from_static("same-origin"),
2345 ),
2346 )
2347 .layer(
2348 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2349 axum::http::HeaderName::from_static("permissions-policy"),
2350 axum::http::HeaderValue::from_static(
2351 "camera=(), microphone=(), geolocation=(), payment=()",
2352 ),
2353 ),
2354 )
2355 .layer(
2356 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2357 axum::http::HeaderName::from_static("cross-origin-opener-policy"),
2358 axum::http::HeaderValue::from_static("same-origin"),
2359 ),
2360 )
2361 .layer(
2364 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2365 header::STRICT_TRANSPORT_SECURITY,
2366 if secure {
2367 Some(axum::http::HeaderValue::from_static(
2368 "max-age=31536000; includeSubDomains",
2369 ))
2370 } else {
2371 None
2372 },
2373 ),
2374 )
2375 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
2376 handle_panic,
2377 ))
2378 .with_state(state)
2379}
2380
2381pub 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'";
2385
2386async fn not_found_fallback(
2389 State(state): State<AdminState>,
2390 method: axum::http::Method,
2391 uri: axum::http::Uri,
2392) -> AdminError {
2393 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
2394 AdminError::NotFound
2395}
2396
2397async fn not_found_plain() -> AdminError {
2399 AdminError::NotFound
2400}
2401
2402fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
2404 let cause = err
2405 .downcast_ref::<&str>()
2406 .map(|s| s.to_string())
2407 .or_else(|| err.downcast_ref::<String>().cloned())
2408 .unwrap_or_else(|| "unknown panic".to_string());
2409 tracing::error!(panic = cause, "admin handler panicked");
2410 error::masked_500()
2411}
2412
2413pub fn normalize_path(path: &str) -> String {
2418 let trimmed = path.trim().trim_matches('/');
2419 if trimmed.is_empty() {
2420 "/admin".to_string()
2421 } else {
2422 format!("/{trimmed}")
2423 }
2424}
2425
2426#[allow(clippy::too_many_arguments)]
2430fn mount_public(
2431 regs: &[routes::PublicRouteReg],
2432 db: &Db,
2433 auth: &AuthService,
2434 parameters: &laterite_core::parameters::Parameters,
2435 upload_extensions: &Arc<Vec<String>>,
2436 field_types: &Arc<field::FieldRegistry>,
2437 overrides: &Arc<dyn field::OverrideResolver>,
2438 signing_key: &Arc<Vec<u8>>,
2439 storage_dir: &str,
2440 admin_path: &str,
2441 origin: &str,
2442 plugin_defined: &Arc<laterite_core::Registry>,
2443 events: &laterite_core::Events,
2444 forms: &form::Forms,
2445) -> Router<AdminState> {
2446 let mut router = Router::new();
2447 for reg in regs.iter().filter(|r| !r.is_site()) {
2449 let ctx = routes::RouteCtx::new(
2450 db.clone(),
2451 auth.clone(),
2452 parameters.clone(),
2453 upload_extensions.clone(),
2454 field_types.clone(),
2455 overrides.clone(),
2456 signing_key.clone(),
2457 storage_dir,
2458 ®.path,
2459 admin_path,
2460 origin,
2461 plugin_defined.clone(),
2462 events.clone(),
2463 forms.clone(),
2464 );
2465 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2470 }
2471 router
2472}
2473
2474pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2477 let old = resource.base_path.clone();
2478 resource.base_path = base.to_string();
2479 if let Some(edit_base) = &mut resource.list.edit_base {
2480 if edit_base == &old {
2481 *edit_base = base.to_string();
2482 }
2483 }
2484 if let Some(form) = &mut resource.form {
2485 if form.base_path == old {
2486 form.base_path = base.to_string();
2487 }
2488 }
2489}
2490
2491fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2492 resource.base_path = format!("{admin_path}{}", resource.base_path);
2493 if let Some(edit_base) = &mut resource.list.edit_base {
2494 *edit_base = format!("{admin_path}{edit_base}");
2495 }
2496 if let Some(form) = &mut resource.form {
2497 form.base_path = format!("{admin_path}{}", form.base_path);
2498 }
2499}
2500
2501#[derive(Debug, Clone)]
2505pub struct AdminAsset {
2506 pub mime: &'static str,
2507 pub bytes: std::borrow::Cow<'static, [u8]>,
2511}
2512
2513impl AdminAsset {
2514 pub fn new(mime: &'static str, bytes: &'static [u8]) -> Self {
2516 Self {
2517 mime,
2518 bytes: std::borrow::Cow::Borrowed(bytes),
2519 }
2520 }
2521
2522 pub fn css(bytes: &'static [u8]) -> Self {
2524 Self::new("text/css; charset=utf-8", bytes)
2525 }
2526
2527 pub fn js(bytes: &'static [u8]) -> Self {
2529 Self::new("text/javascript; charset=utf-8", bytes)
2530 }
2531}
2532
2533#[derive(Debug, Clone)]
2535pub struct AssetReg {
2536 pub key: &'static str,
2537 pub asset: AdminAsset,
2538}
2539
2540pub(crate) fn upload_extensions(configured: &[String]) -> Vec<String> {
2544 let listed: Vec<String> = configured
2545 .iter()
2546 .map(|e| e.trim().trim_start_matches('.').to_ascii_lowercase())
2547 .filter(|e| !e.is_empty())
2548 .collect();
2549 if listed.is_empty() {
2550 laterite_media::sanitize::DEFAULT_EXTENSIONS
2551 .iter()
2552 .map(|e| e.to_string())
2553 .collect()
2554 } else {
2555 listed
2556 }
2557}
2558
2559pub const SETUP_COMPLETED_AT: &str = "laterite.backend::setup.completed_at";
2564
2565pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2568
2569pub(crate) type AssetUrls = HashMap<&'static str, String>;
2573
2574pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2576 registry
2577 .iter()
2578 .map(|(&key, asset)| {
2579 (
2580 key,
2581 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2582 )
2583 })
2584 .collect()
2585}
2586
2587pub(crate) fn page_assets(
2594 keys: &[&str],
2595 base: &str,
2596 registry: &AssetRegistry,
2597 urls: &AssetUrls,
2598) -> Vec<PageAsset> {
2599 let mut seen = std::collections::HashSet::new();
2600 let mut out = Vec::new();
2601 for &key in keys {
2602 if !seen.insert(key) {
2603 continue;
2604 }
2605 match (registry.get(key), urls.get(key)) {
2606 (Some(asset), Some(path)) => out.push(PageAsset {
2607 url: format!("{base}/assets/{path}"),
2608 css: asset.mime.contains("css"),
2609 }),
2610 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2611 }
2612 }
2613 out
2614}
2615
2616pub const CODE_EDITOR_ASSETS: &[&str] = &[
2622 "vendor/codeeditor.js",
2623 "fields/codeeditor.js",
2624 "fields/codeeditor.css",
2625];
2626
2627pub const RICH_EDITOR_ASSETS: &[&str] = &[
2631 "vendor/richeditor.js",
2632 "vendor/richeditor.css",
2633 "fields/richeditor.js",
2634 "fields/richeditor.css",
2635];
2636
2637pub const MARKDOWN_ASSETS: &[&str] = &[
2641 "vendor/codeeditor.js",
2642 "fields/codeeditor.css",
2643 "fields/markdown.js",
2644 "fields/markdown.css",
2645];
2646
2647pub(crate) fn builtin_assets() -> AssetRegistry {
2658 let font = |bytes: &'static [u8]| AdminAsset {
2659 mime: "font/woff2",
2660 bytes: std::borrow::Cow::Borrowed(bytes),
2661 };
2662 HashMap::from([
2663 (
2664 "laterite.css",
2665 AdminAsset {
2666 mime: "text/css; charset=utf-8",
2667 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2668 },
2669 ),
2670 (
2671 "laterite.js",
2672 AdminAsset {
2673 mime: "text/javascript; charset=utf-8",
2674 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2675 },
2676 ),
2677 (
2678 skin::COMPACT_STYLESHEET,
2679 AdminAsset::css(include_bytes!("../assets/skins/compact.css")),
2680 ),
2681 (
2682 "theme-boot.js",
2683 AdminAsset {
2684 mime: "text/javascript; charset=utf-8",
2685 bytes: std::borrow::Cow::Borrowed(laterite_core::theme::BOOT_JS.as_bytes()),
2686 },
2687 ),
2688 (
2689 "vendor/htmx.min.js",
2690 AdminAsset {
2691 mime: "text/javascript; charset=utf-8",
2692 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2693 },
2694 ),
2695 (
2696 "vendor/richeditor.js",
2697 AdminAsset {
2698 mime: "text/javascript; charset=utf-8",
2699 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/richeditor.js")),
2700 },
2701 ),
2702 (
2703 "vendor/richeditor.css",
2704 AdminAsset {
2705 mime: "text/css; charset=utf-8",
2706 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2707 "../assets/vendor/richeditor.css"
2708 )),
2709 },
2710 ),
2711 (
2712 "vendor/codeeditor.js",
2713 AdminAsset {
2714 mime: "text/javascript; charset=utf-8",
2715 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/codeeditor.js")),
2716 },
2717 ),
2718 (
2719 "fields/richeditor.js",
2720 AdminAsset {
2721 mime: "text/javascript; charset=utf-8",
2722 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/richeditor.js")),
2723 },
2724 ),
2725 (
2726 "fields/richeditor.css",
2727 AdminAsset {
2728 mime: "text/css; charset=utf-8",
2729 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2730 "../assets/fields/richeditor.css"
2731 )),
2732 },
2733 ),
2734 (
2735 "fields/markdown.js",
2736 AdminAsset {
2737 mime: "text/javascript; charset=utf-8",
2738 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.js")),
2739 },
2740 ),
2741 (
2742 "fields/markdown.css",
2743 AdminAsset {
2744 mime: "text/css; charset=utf-8",
2745 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.css")),
2746 },
2747 ),
2748 (
2749 "fields/codeeditor.js",
2750 AdminAsset {
2751 mime: "text/javascript; charset=utf-8",
2752 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/codeeditor.js")),
2753 },
2754 ),
2755 (
2756 "fields/codeeditor.css",
2757 AdminAsset {
2758 mime: "text/css; charset=utf-8",
2759 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2760 "../assets/fields/codeeditor.css"
2761 )),
2762 },
2763 ),
2764 (
2765 "fields/ref-picker.js",
2766 AdminAsset {
2767 mime: "text/javascript; charset=utf-8",
2768 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2769 },
2770 ),
2771 (
2772 "fields/ref-picker.css",
2773 AdminAsset {
2774 mime: "text/css; charset=utf-8",
2775 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2776 "../assets/fields/ref-picker.css"
2777 )),
2778 },
2779 ),
2780 (
2781 "mark.svg",
2782 AdminAsset {
2783 mime: "image/svg+xml",
2784 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2785 },
2786 ),
2787 (
2788 "mark.png",
2789 AdminAsset {
2790 mime: "image/png",
2791 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2792 },
2793 ),
2794 (
2795 "fonts/space-grotesk-500.woff2",
2796 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2797 ),
2798 (
2799 "fonts/space-grotesk-600.woff2",
2800 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2801 ),
2802 (
2803 "fonts/space-grotesk-700.woff2",
2804 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2805 ),
2806 (
2807 "fonts/ibm-plex-sans-400.woff2",
2808 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2809 ),
2810 (
2811 "fonts/ibm-plex-sans-600.woff2",
2812 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2813 ),
2814 (
2815 "fonts/ibm-plex-mono-400.woff2",
2816 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2817 ),
2818 (
2819 "fonts/ibm-plex-mono-600.woff2",
2820 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2821 ),
2822 ])
2823}
2824
2825async fn serve_asset(
2827 State(state): State<AdminState>,
2828 headers: HeaderMap,
2829 Path(path): Path<String>,
2830) -> Response {
2831 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2835 let Some(asset) = state.assets.get(key.as_str()) else {
2836 return not_found();
2837 };
2838 let policy = if fingerprinted {
2839 http_cache::IMMUTABLE
2840 } else {
2841 http_cache::REVALIDATE
2842 };
2843 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2844}
2845
2846fn mount_resource(
2851 resource: &Resource,
2852 field_types: &field::FieldRegistry,
2853 column_types: &list::ColumnRegistry,
2854 permission_codes: &std::collections::HashSet<String>,
2855 persisters: &persist::PersisterRegistry,
2856 listeners: &[laterite_core::ModelListenerReg],
2857) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2858 list::check(&resource.list, column_types, permission_codes)
2862 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2863 if resource.form.is_some() && resource.permission.is_none() {
2868 panic!(
2869 "admin resource `{}` has a create/edit form but no permission; a write \
2870 resource must declare a permission",
2871 resource.base_path
2872 );
2873 }
2874 let base = resource.base_path.clone();
2875 let list_path = base.clone();
2876 let mut list_cfg = resource.list.clone();
2877 if resource.list.columns.iter().any(|c| c.editable) {
2880 let Some(form_cfg) = resource.form.clone() else {
2881 panic!(
2882 "admin resource `{}` has an editable column but no form to edit through",
2883 resource.base_path
2884 );
2885 };
2886 for column in resource.list.columns.iter().filter(|c| c.editable) {
2887 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2888 panic!(
2889 "admin resource `{}`: column `{}` is editable but the form has no such field",
2890 resource.base_path, column.field
2891 );
2892 }
2893 }
2894 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2895 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2896 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2897 }
2898 let cell_cfg = list_cfg.clone();
2899 let mut router = Router::new().route(
2900 &base,
2901 get(
2902 move |State(state): State<AdminState>,
2903 Extension(shell): Extension<Shell>,
2904 Query(params): Query<list::ListParams>,
2905 Query(raw): Query<std::collections::HashMap<String, String>>,
2906 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2907 Extension(user): Extension<AuthenticatedUser>,
2908 Extension(session): Extension<session::SessionHandle>,
2909 headers: axum::http::HeaderMap| {
2910 let cfg = list_cfg.clone();
2911 let path = list_path.clone();
2912 async move {
2913 list::handle_remembering(
2914 &state,
2915 &cfg,
2916 &path,
2917 params,
2918 &raw,
2919 &user,
2920 shell,
2921 &headers,
2922 query_string.as_deref(),
2923 Some(&session),
2924 )
2925 .await
2926 }
2927 },
2928 ),
2929 );
2930
2931 if cell_cfg.editor.is_some() {
2932 let (cfg, list_path) = (cell_cfg, base.clone());
2933 router = router.route(
2934 &format!("{base}/{{id}}/cell"),
2935 post(
2936 move |State(state): State<AdminState>,
2937 Extension(shell): Extension<Shell>,
2938 Extension(user): Extension<AuthenticatedUser>,
2939 Extension(session): Extension<session::SessionHandle>,
2940 Path(id): Path<String>,
2941 headers: axum::http::HeaderMap,
2942 Form(data): Form<HashMap<String, String>>| {
2943 let (cfg, path) = (cfg.clone(), list_path.clone());
2944 async move {
2945 list::cell_handler(
2946 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2947 )
2948 .await
2949 }
2950 },
2951 ),
2952 );
2953 }
2954
2955 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2958 if resource.list.exportable {
2959 router = router.route(
2960 &format!("{base}/export"),
2961 get(
2962 move |state: State<AdminState>,
2963 Query(raw): Query<std::collections::HashMap<String, String>>,
2964 Extension(user): Extension<AuthenticatedUser>,
2965 Extension(shell): Extension<Shell>,
2966 Extension(session): Extension<session::SessionHandle>| {
2967 let cfg = export_cfg.clone();
2968 let path = export_path.clone();
2969 async move {
2970 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2971 }
2972 },
2973 ),
2974 );
2975 }
2976
2977 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2978 router = router.route(
2979 &format!("{base}/columns"),
2980 post(
2981 move |State(state): State<AdminState>,
2982 Extension(user): Extension<AuthenticatedUser>,
2983 Extension(session): Extension<session::SessionHandle>,
2984 headers: axum::http::HeaderMap,
2985 Form(pairs): Form<Vec<(String, String)>>| {
2986 let cfg = columns_cfg.clone();
2987 let path = columns_path.clone();
2988 async move {
2989 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2990 }
2991 },
2992 ),
2993 );
2994
2995 if resource.list.deletable {
2996 let persister: Arc<dyn persist::Persister> =
3000 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
3001 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
3002 panic!(
3003 "admin resource `{}`: unregistered persister `{key}`",
3004 resource.base_path
3005 )
3006 }),
3007 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
3008 };
3009 let ctx = Arc::new(bulk::BulkContext {
3010 base_path: resource.base_path.clone(),
3011 entity: resource.list.entity.clone(),
3012 persister,
3013 listeners: listeners
3014 .iter()
3015 .filter(|reg| reg.matches(&resource.list.entity))
3016 .map(|reg| reg.listener.clone())
3017 .collect(),
3018 });
3019 router =
3020 router.route(
3021 &format!("{base}/delete"),
3022 post(
3023 move |state: State<AdminState>,
3024 user: Extension<AuthenticatedUser>,
3025 session: Extension<session::SessionHandle>,
3026 headers: axum::http::HeaderMap,
3027 form: Form<Vec<(String, String)>>| {
3028 let ctx = ctx.clone();
3029 async move {
3030 bulk::delete_handler(state, user, session, ctx, headers, form).await
3031 }
3032 },
3033 ),
3034 );
3035 }
3036
3037 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
3040 let tree = resource.list.tree.clone().unwrap_or_default();
3041 let mut move_cfg = form::FormConfig::new(
3042 resource.list.entity.clone(),
3043 laterite_core::t!("Move to"),
3044 base.clone(),
3045 resource.list.id_field.clone(),
3046 vec![form::FormField::reference(
3047 &tree.parent_key,
3048 laterite_core::t!("New parent"),
3049 &source,
3050 )
3051 .help(laterite_core::t!("Leave empty for the top level."))],
3052 );
3053 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
3054 move_cfg = move_cfg.persist(persist);
3055 }
3056 move_cfg.done_path = Some(base.clone());
3057 let move_form = Arc::new(
3058 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
3059 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
3060 );
3061 let (cfg, list_path) = (resource.list.clone(), base.clone());
3062 let (form_get, form_post) = (move_form.clone(), move_form);
3063 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
3064 router = router.route(
3065 &format!("{base}/{{id}}/move"),
3066 get(
3067 move |State(state): State<AdminState>,
3068 Extension(shell): Extension<Shell>,
3069 Path(id): Path<String>| {
3070 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
3071 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
3072 },
3073 )
3074 .post(
3075 move |State(state): State<AdminState>,
3076 Extension(shell): Extension<Shell>,
3077 Extension(user): Extension<AuthenticatedUser>,
3078 Extension(session): Extension<session::SessionHandle>,
3079 Path(id): Path<String>,
3080 headers: axum::http::HeaderMap,
3081 Form(data): Form<HashMap<String, String>>| {
3082 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
3083 async move {
3084 list::move_record(
3085 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
3086 )
3087 .await
3088 }
3089 },
3090 ),
3091 );
3092 }
3093
3094 if resource.list.reorder.is_some() {
3095 let (cfg, list_path) = (resource.list.clone(), base.clone());
3096 router = router.route(
3097 &format!("{base}/reorder"),
3098 post(
3099 move |State(state): State<AdminState>,
3100 Extension(shell): Extension<Shell>,
3101 Extension(user): Extension<AuthenticatedUser>,
3102 Extension(session): Extension<session::SessionHandle>,
3103 headers: axum::http::HeaderMap,
3104 Form(pairs): Form<Vec<(String, String)>>| {
3105 let cfg = cfg.clone();
3106 let path = list_path.clone();
3107 async move {
3108 list::reorder_handler(
3109 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
3110 &headers, &pairs,
3111 )
3112 .await
3113 }
3114 },
3115 ),
3116 );
3117 }
3118
3119 if !resource.relations.is_empty() && resource.form.is_none() {
3122 panic!(
3123 "admin resource `{}` declares relations but has no form to show them on",
3124 resource.base_path
3125 );
3126 }
3127 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
3128 for rel in &resource.relations {
3129 let prepared = relation::PreparedRelation::prepare(
3130 resource,
3131 rel.clone(),
3132 field_types,
3133 column_types,
3134 permission_codes,
3135 persisters,
3136 listeners,
3137 )
3138 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3139 relations.push(Arc::new(prepared));
3140 }
3141 let relations = Arc::new(relations);
3142
3143 let mut prepared_form = None;
3144 if let Some(form_cfg) = resource.form.clone() {
3145 let mut prepared =
3148 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
3149 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3150 prepared.stay = !relations.is_empty();
3152 let prepared = Arc::new(prepared);
3153 prepared_form = Some(prepared.clone());
3154 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
3155 let relations_for_new = relations.clone();
3156 router = router.route(
3157 &format!("{base}/new"),
3158 get(
3159 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
3160 let pf = new_pf.clone();
3161 let relations = relations_for_new.clone();
3162 async move {
3163 let pending = relations
3164 .iter()
3165 .map(|r| shell.tt(&r.config.title))
3166 .collect();
3167 form::new_form(&state, &pf, shell, pending)
3168 }
3169 },
3170 )
3171 .post(
3172 move |State(state): State<AdminState>,
3173 Extension(shell): Extension<Shell>,
3174 Extension(user): Extension<AuthenticatedUser>,
3175 Extension(session): Extension<session::SessionHandle>,
3176 headers: axum::http::HeaderMap,
3177 Form(data): Form<HashMap<String, String>>| {
3178 let pf = create_pf.clone();
3179 async move {
3180 form::create(&state, &pf, data, shell, &user, &session, &headers).await
3181 }
3182 },
3183 ),
3184 );
3185
3186 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
3187 let relations_for_edit = relations.clone();
3188 router = router.route(
3189 &format!("{base}/{{id}}/edit"),
3190 get(
3191 move |State(state): State<AdminState>,
3192 Extension(shell): Extension<Shell>,
3193 Extension(user): Extension<AuthenticatedUser>,
3194 Path(id): Path<String>| {
3195 let pf = edit_pf.clone();
3196 let relations = relations_for_edit.clone();
3197 async move {
3198 let mut sections = Vec::new();
3199 for rel in relations.iter() {
3200 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
3201 sections.push(section);
3202 }
3203 }
3204 form::edit_form(&state, &pf, id, shell, sections).await
3205 }
3206 },
3207 )
3208 .post(
3209 move |State(state): State<AdminState>,
3210 Extension(shell): Extension<Shell>,
3211 Extension(user): Extension<AuthenticatedUser>,
3212 Extension(session): Extension<session::SessionHandle>,
3213 Path(id): Path<String>,
3214 headers: axum::http::HeaderMap,
3215 Form(data): Form<HashMap<String, String>>| {
3216 let pf = update_pf.clone();
3217 async move {
3218 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
3219 }
3220 },
3221 ),
3222 );
3223 }
3224
3225 for rel in relations.iter() {
3226 let name = rel.config.name.clone();
3227 let mut routes: Router<AdminState> = Router::new();
3228 let r = rel.clone();
3229 routes =
3230 routes.route(
3231 &format!("{base}/{{id}}/{name}"),
3232 get(
3233 move |state: State<AdminState>,
3234 shell: Extension<Shell>,
3235 user: Extension<AuthenticatedUser>,
3236 id: Path<String>,
3237 params: Query<list::ListParams>,
3238 raw: Query<HashMap<String, String>>,
3239 headers: axum::http::HeaderMap| {
3240 let r = r.clone();
3241 async move {
3242 relation::index(state, shell, user, id, params, raw, headers, r).await
3243 }
3244 },
3245 ),
3246 );
3247 let (r_new, r_create) = (rel.clone(), rel.clone());
3248 routes = routes.route(
3249 &format!("{base}/{{id}}/{name}/new"),
3250 get(
3251 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
3252 let r = r_new.clone();
3253 async move { relation::new_form(state, shell, id, r).await }
3254 },
3255 )
3256 .post(
3257 move |state: State<AdminState>,
3258 shell: Extension<Shell>,
3259 user: Extension<AuthenticatedUser>,
3260 session: Extension<session::SessionHandle>,
3261 id: Path<String>,
3262 headers: axum::http::HeaderMap,
3263 data: Form<HashMap<String, String>>| {
3264 let r = r_create.clone();
3265 async move {
3266 relation::create(state, shell, user, session, id, headers, data, r).await
3267 }
3268 },
3269 ),
3270 );
3271 let (r_edit, r_update) = (rel.clone(), rel.clone());
3272 routes = routes.route(
3273 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
3274 get(
3275 move |state: State<AdminState>,
3276 shell: Extension<Shell>,
3277 ids: Path<(String, String)>| {
3278 let r = r_edit.clone();
3279 async move { relation::edit_form(state, shell, ids, r).await }
3280 },
3281 )
3282 .post(
3283 move |state: State<AdminState>,
3284 shell: Extension<Shell>,
3285 user: Extension<AuthenticatedUser>,
3286 session: Extension<session::SessionHandle>,
3287 ids: Path<(String, String)>,
3288 headers: axum::http::HeaderMap,
3289 data: Form<HashMap<String, String>>| {
3290 let r = r_update.clone();
3291 async move {
3292 relation::update(state, shell, user, session, ids, headers, data, r).await
3293 }
3294 },
3295 ),
3296 );
3297 let r_delete = rel.clone();
3298 routes = routes.route(
3299 &format!("{base}/{{id}}/{name}/delete"),
3300 post(
3301 move |state: State<AdminState>,
3302 shell: Extension<Shell>,
3303 user: Extension<AuthenticatedUser>,
3304 session: Extension<session::SessionHandle>,
3305 id: Path<String>,
3306 headers: axum::http::HeaderMap,
3307 pairs: Form<Vec<(String, String)>>| {
3308 let r = r_delete.clone();
3309 async move {
3310 relation::delete(state, shell, user, session, id, headers, pairs, r).await
3311 }
3312 },
3313 ),
3314 );
3315 if rel.reorderable() {
3316 let r_reorder = rel.clone();
3317 routes = routes.route(
3318 &format!("{base}/{{id}}/{name}/reorder"),
3319 post(
3320 move |state: State<AdminState>,
3321 shell: Extension<Shell>,
3322 user: Extension<AuthenticatedUser>,
3323 session: Extension<session::SessionHandle>,
3324 id: Path<String>,
3325 headers: axum::http::HeaderMap,
3326 pairs: Form<Vec<(String, String)>>| {
3327 let r = r_reorder.clone();
3328 async move {
3329 relation::reorder(state, shell, user, session, id, headers, pairs, r)
3330 .await
3331 }
3332 },
3333 ),
3334 );
3335 }
3336 if let Some(permission) = &rel.config.permission {
3337 routes = guard_with_permission(routes, permission);
3338 }
3339 router = router.merge(routes);
3340 }
3341
3342 if let Some(permission) = &resource.permission {
3344 router = guard_with_permission(router, permission);
3345 }
3346 (router, prepared_form)
3347}
3348
3349fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
3354 let needed: Arc<str> = Arc::from(permission);
3355 router.route_layer(middleware::from_fn(
3356 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
3357 let needed = needed.clone();
3358 async move {
3359 if user.allows(&needed) {
3360 next.run(req).await
3361 } else {
3362 forbidden()
3363 }
3364 }
3365 },
3366 ))
3367}
3368
3369async fn require_auth(
3375 State(state): State<AdminState>,
3376 jar: CookieJar,
3377 request: Request,
3378 next: Next,
3379) -> Response {
3380 let login = format!("{}/login", state.admin_path);
3381 let client_ctx = request
3382 .extensions()
3383 .get::<RequestContext>()
3384 .cloned()
3385 .unwrap_or_default();
3386 let mut ended: Option<laterite_auth::SessionEnd> = None;
3391 let live = match jar.get(SESSION_COOKIE) {
3392 Some(cookie) => {
3393 let token = cookie.value().to_string();
3394 match state.auth.resolve_session(&token).await {
3395 Ok(resolved) => Some((token, resolved)),
3396 Err(_) => {
3397 ended = state.auth.session_end(&token).await.ok().flatten();
3398 None
3399 }
3400 }
3401 }
3402 None => None,
3403 };
3404 let htmx = request.headers().contains_key("hx-request");
3405 let (token, resolved, recalled) = match live {
3406 Some((token, resolved)) => (token, resolved, None),
3407 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
3410 return signed_out(&state, jar, &login, ended, htmx);
3411 }
3412 None => match recall(&state, &jar, &client_ctx).await {
3413 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
3414 None => {
3415 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3419 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
3420 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
3421 }
3422 }
3423 return signed_out(&state, jar, &login, ended, htmx);
3424 }
3425 },
3426 };
3427 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
3428
3429 let mut request = request;
3434 let safe = session::is_safe_method(request.method());
3435 let mut deferred: Option<session::CsrfPending> = None;
3437 if !safe {
3438 let (parts, body) = request.into_parts();
3439 let (body, submitted) = if session::is_multipart(&parts.headers) {
3446 let token = session::header_token(&parts.headers)
3447 .or_else(|| session::query_token(parts.uri.query()));
3448 if token.is_none() {
3453 deferred = Some(session::CsrfPending::default());
3454 }
3455 (body, token)
3456 } else {
3457 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
3458 .await
3459 .unwrap_or_default();
3460 let token = session::submitted_token(&parts.headers, &bytes);
3461 (Body::from(bytes), token)
3462 };
3463 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
3464 {
3465 tracing::warn!(
3466 user_id = resolved.identity.user.id,
3467 "admin CSRF check failed"
3468 );
3469 return error::csrf_rejected();
3470 }
3471 request = Request::from_parts(parts, body);
3472 }
3473
3474 let user = resolved.identity;
3475 let acting_user_id = user.user.id;
3477 let path = request.uri().path().to_string();
3478 let preferences = format!("{}/preferences", state.admin_path);
3481 if !path.starts_with(&preferences)
3482 && path != format!("{}/logout", state.admin_path)
3483 && state
3484 .auth
3485 .must_change_password(user.user.id)
3486 .await
3487 .unwrap_or(false)
3488 {
3489 let to = format!("{preferences}#password");
3490 let response = if htmx {
3491 (
3492 [("HX-Redirect", to.as_str())],
3493 axum::http::StatusCode::NO_CONTENT,
3494 )
3495 .into_response()
3496 } else {
3497 Redirect::to(&to).into_response()
3498 };
3499 return with_recall_cookies(response, recalled, jar, token, &state);
3500 }
3501 let flash = if safe {
3504 handle.take_flash()
3505 } else {
3506 Vec::new()
3507 };
3508 let accept_language = request
3512 .headers()
3513 .get("accept-language")
3514 .and_then(|v| v.to_str().ok());
3515 let serveable = state.catalogs.locales();
3516 let chain = resolve_locale_chain(
3517 user.user.locale.as_deref(),
3518 accept_language,
3519 &state.default_locale,
3520 &serveable,
3521 );
3522 let i18n = Translator::with_chain(chain, state.catalogs.clone());
3523 let translator = i18n.clone();
3526 let own = OwnMenu::load(&state, user.user.id).await;
3528 let (own_nav, own_settings) = own_tree(&state, &own);
3529 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
3530 &own_nav,
3531 &own_settings,
3532 &state.admin_path,
3533 &user.permissions,
3534 own.nav_order.as_deref(),
3535 &path,
3536 &i18n,
3537 icons::Icons::new(&state.icons, &state.sprite_url),
3538 &state.order,
3539 own.settings_order.as_deref(),
3540 );
3541 let brand = state.brand().await;
3542 let shell = Shell::new(
3543 &state.admin_path,
3544 brand,
3545 state.skin.clone(),
3546 &visible_nav,
3547 &user,
3548 state.timezone,
3549 sidebar,
3550 active_nav.as_deref(),
3551 handle.csrf_token(),
3552 flash,
3553 i18n,
3554 state.asset_urls.clone(),
3555 state.origin.to_string(),
3556 state.icons.clone(),
3557 state.sprite_url.clone(),
3558 );
3559 request.extensions_mut().insert(user);
3560 request.extensions_mut().insert(shell);
3561 request.extensions_mut().insert(handle.clone());
3562 if let Some(pending) = &deferred {
3564 request.extensions_mut().insert(pending.clone());
3565 }
3566 let mut response = next.run(request).await;
3567
3568 if let Some(pending) = &deferred {
3573 if !pending.was_satisfied() {
3574 tracing::error!(
3575 user_id = acting_user_id,
3576 "a multipart route returned without verifying its request token"
3577 );
3578 return error::csrf_rejected();
3579 }
3580 }
3581
3582 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
3586 response = error::localized_error(meta.kind, &translator);
3587 }
3588
3589 if let Some(blob) = handle.dirty_blob() {
3592 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
3593 tracing::error!(error = %e, "persisting admin session failed");
3594 }
3595 }
3596 with_recall_cookies(response, recalled, jar, token, &state)
3597}
3598
3599fn with_recall_cookies(
3603 response: Response,
3604 recalled: Option<laterite_auth::RememberCredential>,
3605 jar: CookieJar,
3606 token: String,
3607 state: &AdminState,
3608) -> Response {
3609 match recalled {
3610 Some(credential) => {
3611 let jar = jar
3612 .add(session_cookie(
3613 token,
3614 &state.cookie_path,
3615 state.secure_cookie,
3616 ))
3617 .add(remember_cookie(
3618 credential,
3619 &state.cookie_path,
3620 state.secure_cookie,
3621 ));
3622 (jar, response).into_response()
3623 }
3624 None => response,
3625 }
3626}
3627
3628async fn recall(
3632 state: &AdminState,
3633 jar: &CookieJar,
3634 ctx: &RequestContext,
3635) -> Option<(
3636 String,
3637 laterite_auth::ResolvedSession,
3638 laterite_auth::RememberCredential,
3639)> {
3640 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3641 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3642 let token = recalled.session.token.clone();
3643 let resolved = state.auth.resolve_session(&token).await.ok()?;
3644 Some((token, resolved, recalled.remember))
3645}
3646
3647const MAX_FORM_BYTES: usize = 1024 * 1024;
3650
3651async fn capture_client(
3659 State(state): State<AdminState>,
3660 mut request: Request,
3661 next: Next,
3662) -> Response {
3663 let peer = request
3664 .extensions()
3665 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3666 .map(|info| info.0);
3667 let headers = request.headers();
3668 let ctx = RequestContext {
3669 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3670 user_agent: clientip::user_agent(headers),
3671 };
3672 request.extensions_mut().insert(ctx);
3673 next.run(request).await
3674}
3675
3676async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3677 if !session::is_safe_method(request.method())
3678 && !session::origin_ok(request.headers(), &state.origin)
3679 {
3680 tracing::warn!(
3681 path = request.uri().path(),
3682 "admin CSRF origin check failed"
3683 );
3684 return error::csrf_rejected();
3685 }
3686 next.run(request).await
3687}
3688
3689#[derive(Deserialize)]
3690struct LoginQuery {
3691 ended: Option<String>,
3692}
3693
3694async fn login_form(
3695 State(state): State<AdminState>,
3696 headers: HeaderMap,
3697 Query(query): Query<LoginQuery>,
3698) -> Response {
3699 match state.auth.has_any_operator().await {
3701 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3702 Ok(true) => {
3703 let i18n = pre_auth_translator(&state, &headers);
3704 let notice = query
3705 .ended
3706 .as_deref()
3707 .and_then(end_notice)
3708 .map(|text| i18n.t(&text));
3709 render(LoginTemplate {
3710 base: state.admin_path.to_string(),
3711 asset_urls: state.asset_urls.clone(),
3712 brand: state.brand().await.name.clone(),
3713 look: state.brand().await,
3714 skin: state.skin.clone(),
3715 error: None,
3716 notice,
3717 i18n,
3718 })
3719 }
3720 Err(_) => render_error(),
3721 }
3722}
3723
3724#[derive(Deserialize)]
3725struct LoginForm {
3726 username: String,
3727 password: String,
3728 #[serde(default)]
3730 remember: Option<String>,
3731}
3732
3733async fn login_submit(
3734 State(state): State<AdminState>,
3735 jar: CookieJar,
3736 headers: HeaderMap,
3737 Extension(ctx): Extension<RequestContext>,
3738 Form(form): Form<LoginForm>,
3739) -> Response {
3740 match state
3741 .auth
3742 .authenticate(&form.username, &form.password, &ctx)
3743 .await
3744 {
3745 Ok(session) => {
3746 let wants_remember = form.remember.is_some();
3747 let user_id = session.user_id;
3748 let mut jar = jar.add(session_cookie(
3749 session.token,
3750 &state.cookie_path,
3751 state.secure_cookie,
3752 ));
3753 if wants_remember {
3754 match state.auth.issue_remember(user_id).await {
3755 Ok(credential) => {
3756 jar = jar.add(remember_cookie(
3757 credential,
3758 &state.cookie_path,
3759 state.secure_cookie,
3760 ));
3761 }
3762 Err(e) => {
3764 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3765 }
3766 }
3767 }
3768 (jar, Redirect::to(&state.admin_path)).into_response()
3769 }
3770 Err(_) => {
3771 let i18n = pre_auth_translator(&state, &headers);
3772 render(LoginTemplate {
3773 base: state.admin_path.to_string(),
3774 asset_urls: state.asset_urls.clone(),
3775 brand: state.brand().await.name.clone(),
3776 look: state.brand().await,
3777 skin: state.skin.clone(),
3778 error: Some(i18n.t(&t!("Invalid username or password."))),
3779 notice: None,
3780 i18n,
3781 })
3782 }
3783 }
3784}
3785
3786fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3796 Cookie::build((SESSION_COOKIE, token))
3797 .path(admin_path.to_string())
3798 .http_only(true)
3799 .secure(secure)
3800 .same_site(SameSite::Lax)
3801 .build()
3802}
3803
3804fn remember_cookie(
3807 credential: laterite_auth::RememberCredential,
3808 admin_path: &str,
3809 secure: bool,
3810) -> Cookie<'static> {
3811 let seconds = (credential.expires_at - chrono::Utc::now())
3812 .num_seconds()
3813 .max(0);
3814 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3815 .path(admin_path.to_string())
3816 .http_only(true)
3817 .secure(secure)
3818 .same_site(SameSite::Lax)
3819 .max_age(cookie::time::Duration::seconds(seconds))
3820 .build()
3821}
3822
3823fn signed_out(
3830 state: &AdminState,
3831 jar: CookieJar,
3832 login: &str,
3833 ended: Option<laterite_auth::SessionEnd>,
3834 htmx: bool,
3835) -> Response {
3836 let to = match ended.map(laterite_auth::SessionEnd::code) {
3837 Some(code) => format!("{login}?ended={code}"),
3838 None => login.to_string(),
3839 };
3840 let jar = jar
3841 .remove(
3842 Cookie::build((SESSION_COOKIE, ""))
3843 .path(state.cookie_path.to_string())
3844 .build(),
3845 )
3846 .remove(remember_removal(&state.cookie_path));
3847 if htmx {
3848 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3849 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3850 response.headers_mut().insert("hx-redirect", value);
3851 }
3852 return response;
3853 }
3854 (jar, Redirect::to(&to)).into_response()
3855}
3856
3857fn end_notice(code: &str) -> Option<Text> {
3860 Some(match code {
3861 "password_changed" => {
3862 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3863 }
3864 "deactivated" => t!("This account was deactivated."),
3865 "signed_out_elsewhere" => t!("You were signed out from another device."),
3866 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3867 _ => return None,
3868 })
3869}
3870
3871fn remember_removal(admin_path: &str) -> Cookie<'static> {
3873 Cookie::build((REMEMBER_COOKIE, ""))
3874 .path(admin_path.to_string())
3875 .build()
3876}
3877
3878#[derive(Deserialize)]
3879struct SetupForm {
3880 username: String,
3881 first_name: String,
3882 last_name: String,
3883 email: String,
3884 password: String,
3885 timezone: String,
3886}
3887
3888async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3891 match state.auth.has_any_operator().await {
3892 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3893 Ok(false) => render(setup_view(
3894 &state.admin_path,
3895 state.brand().await,
3896 state.skin.clone(),
3897 state.timezone,
3898 None,
3899 pre_auth_translator(&state, &headers),
3900 state.asset_urls.clone(),
3901 state.auth.password_policy().min_length,
3902 )),
3903 Err(_) => render_error(),
3904 }
3905}
3906
3907async fn setup_submit(
3908 State(state): State<AdminState>,
3909 jar: CookieJar,
3910 headers: HeaderMap,
3911 client: Option<Extension<RequestContext>>,
3912 Form(form): Form<SetupForm>,
3913) -> Response {
3914 match state.auth.has_any_operator().await {
3916 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3917 Ok(false) => {}
3918 Err(_) => return render_error(),
3919 }
3920
3921 let username = form.username.trim();
3922 let email = form.email.trim();
3923 let first_name = form.first_name.trim();
3924 let last_name = form.last_name.trim();
3925 let tz = form.timezone.trim();
3926 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3927 {
3928 return render(setup_view(
3929 &state.admin_path,
3930 state.brand().await,
3931 state.skin.clone(),
3932 state.timezone,
3933 Some(t!(
3934 "Username, first name, email, and password are all required."
3935 )),
3936 pre_auth_translator(&state, &headers),
3937 state.asset_urls.clone(),
3938 state.auth.password_policy().min_length,
3939 ));
3940 }
3941 if tz.parse::<Tz>().is_err() {
3943 return render(setup_view(
3944 &state.admin_path,
3945 state.brand().await,
3946 state.skin.clone(),
3947 state.timezone,
3948 Some(t!("That is not a recognised timezone.")),
3949 pre_auth_translator(&state, &headers),
3950 state.asset_urls.clone(),
3951 state.auth.password_policy().min_length,
3952 ));
3953 }
3954
3955 let min_length = state.auth.password_policy().min_length;
3956 if form.password.chars().count() < min_length {
3957 return render(setup_view(
3958 &state.admin_path,
3959 state.brand().await,
3960 state.skin.clone(),
3961 state.timezone,
3962 Some(t!(
3963 "A password needs at least {n} characters.",
3964 n = min_length as i64
3965 )),
3966 pre_auth_translator(&state, &headers),
3967 state.asset_urls.clone(),
3968 state.auth.password_policy().min_length,
3969 ));
3970 }
3971 let new = NewOperator {
3972 username,
3973 email,
3974 first_name,
3975 last_name: (!last_name.is_empty()).then_some(last_name),
3976 password: &form.password,
3977 timezone: Some(tz),
3978 };
3979 let id = match state.auth.create_superuser(new).await {
3980 Ok(id) => id,
3981 Err(_) => {
3982 return render(setup_view(
3983 &state.admin_path,
3984 state.brand().await,
3985 state.skin.clone(),
3986 state.timezone,
3987 Some(t!(
3988 "Could not create the account. The username or email may already be taken."
3989 )),
3990 pre_auth_translator(&state, &headers),
3991 state.asset_urls.clone(),
3992 state.auth.password_policy().min_length,
3993 ));
3994 }
3995 };
3996 let actor = laterite_core::Actor::system("first-run setup");
3999 let target = id.to_string();
4000 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
4001 "backend_user",
4002 &target,
4003 Some(username),
4004 );
4005 if let Err(e) = state.auth.record_audit(entry).await {
4006 tracing::error!(error = %e, "failed to write audit log entry");
4007 }
4008 let completed = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4009 if let Err(e) = state.parameters.set(SETUP_COMPLETED_AT, &completed).await {
4010 tracing::warn!(error = %e, "failed to record the setup completion");
4011 }
4012
4013 match state
4015 .auth
4016 .authenticate(
4017 username,
4018 &form.password,
4019 &client.map(|c| c.0).unwrap_or_default(),
4020 )
4021 .await
4022 {
4023 Ok(session) => {
4024 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
4025 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
4026 }
4027 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4028 }
4029}
4030
4031#[allow(clippy::too_many_arguments)]
4034fn setup_view(
4035 admin_path: &str,
4036 brand: Arc<settings::brand::Brand>,
4037 skin: Arc<skin::Active>,
4038 default_tz: Tz,
4039 error: Option<Text>,
4040 i18n: Translator,
4041 asset_urls: Arc<AssetUrls>,
4042 min_password: usize,
4043) -> SetupTemplate {
4044 let default_name = default_tz.name();
4045 let zones = TZ_VARIANTS
4046 .iter()
4047 .filter(|tz| zone_offered(tz.name()))
4048 .map(|tz| TzOption {
4049 name: tz.name().to_string(),
4050 selected: tz.name() == default_name,
4051 })
4052 .collect();
4053 SetupTemplate {
4054 base: admin_path.to_string(),
4055 asset_urls,
4056 brand: brand.name.clone(),
4057 look: brand,
4058 skin,
4059 zones,
4060 error: error.map(|e| i18n.t(&e)),
4061 min_password: min_password as i64,
4062 i18n,
4063 }
4064}
4065
4066async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
4067 if let Some(cookie) = jar.get(SESSION_COOKIE) {
4068 let _ = state.auth.logout(cookie.value()).await;
4069 }
4070 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
4073 let _ = state.auth.revoke_remember(cookie.value()).await;
4074 }
4075 let removal = Cookie::build((SESSION_COOKIE, ""))
4076 .path(state.cookie_path.to_string())
4077 .build();
4078 (
4079 jar.remove(removal)
4080 .remove(remember_removal(&state.cookie_path)),
4081 Redirect::to(&format!("{}/login", state.admin_path)),
4082 )
4083 .into_response()
4084}
4085
4086async fn dashboard(
4087 State(state): State<AdminState>,
4088 Extension(shell): Extension<Shell>,
4089 Extension(user): Extension<AuthenticatedUser>,
4090) -> Response {
4091 let layout = laterite_auth::store::user_preference(&state.db, user.user.id, widget::PREFERENCE)
4092 .await
4093 .ok()
4094 .flatten()
4095 .and_then(|json| widget::Layout::parse(&json));
4096 let deployment = state.order.get(widget::HIERARCHY).map(Vec::as_slice);
4097 let allows = |p: &str| user.permissions.allows(p);
4098 let available = widget::available(&state.widgets, deployment, &allows);
4099 let shown = widget::resolve(layout.as_ref(), &available);
4100 let failed = shell.tt(&t!("This widget could not be shown."));
4101 let mut widgets = Vec::with_capacity(shown.len());
4102 for (reg, size) in &shown {
4103 let cx = widget::WidgetCx {
4104 state: &state,
4105 shell: &shell,
4106 user: &user,
4107 size: *size,
4108 };
4109 let body = reg.widget.render(&cx).await;
4110 widgets.push(widget::frame(
4111 reg,
4112 *size,
4113 &shell.base,
4114 &shell.tt(®.widget.label()),
4115 body,
4116 &failed,
4117 ));
4118 }
4119 let available = available
4120 .iter()
4121 .map(|reg| {
4122 let placed = shown.iter().find(|(r, _)| r.code() == reg.code());
4123 AvailableWidgetView {
4124 code: reg.code().to_string(),
4125 label: shell.tt(®.widget.label()),
4126 description: reg
4127 .widget
4128 .description()
4129 .map(|d| shell.tt(&d))
4130 .unwrap_or_default(),
4131 shown: placed.is_some(),
4132 size: placed
4133 .map(|(_, s)| *s)
4134 .unwrap_or_else(|| reg.widget.size())
4135 .key()
4136 .to_string(),
4137 }
4138 })
4139 .collect();
4140 render(DashboardTemplate {
4141 shell,
4142 widgets,
4143 available,
4144 })
4145}
4146
4147async fn dashboard_widget(
4149 State(state): State<AdminState>,
4150 Extension(shell): Extension<Shell>,
4151 Extension(user): Extension<AuthenticatedUser>,
4152 Path(code): Path<String>,
4153 Query(query): Query<HashMap<String, String>>,
4154) -> Response {
4155 let Some(reg) = state.widgets.iter().find(|w| w.code() == code) else {
4156 return AdminError::NotFound.into_response();
4157 };
4158 if let Some(p) = reg.widget.permission() {
4159 if !user.permissions.allows(p) {
4160 return AdminError::Forbidden.into_response();
4161 }
4162 }
4163 let size = query
4164 .get("size")
4165 .and_then(|s| widget::Size::parse(s))
4166 .unwrap_or_else(|| reg.widget.size());
4167 let cx = widget::WidgetCx {
4168 state: &state,
4169 shell: &shell,
4170 user: &user,
4171 size,
4172 };
4173 match reg.widget.render(&cx).await {
4174 Ok(markup) => Html(markup.into_string()).into_response(),
4175 Err(e) => {
4176 tracing::warn!(widget = reg.code(), error = %e, "widget failed to render");
4177 Html(format!(
4178 r#"<p class="lat-widget__failed">{}</p>"#,
4179 form::escape_attr(&shell.tt(&t!("This widget could not be shown.")))
4180 ))
4181 .into_response()
4182 }
4183 }
4184}
4185
4186async fn dashboard_layout_update(
4189 State(state): State<AdminState>,
4190 Extension(user): Extension<AuthenticatedUser>,
4191 Extension(session): Extension<session::SessionHandle>,
4192 body: String,
4193) -> Response {
4194 let mut layout = widget::Layout::default();
4195 let mut sizes: HashMap<String, widget::Size> = HashMap::new();
4196 let mut order: Vec<String> = Vec::new();
4197 for (key, value) in form_urlencoded::parse(body.as_bytes()) {
4198 if key == "widget" {
4199 if state.widgets.iter().any(|w| w.code() == value) {
4200 order.push(value.to_string());
4201 }
4202 } else if let Some(code) = key.strip_prefix("size:") {
4203 if let Some(size) = widget::Size::parse(&value) {
4204 sizes.insert(code.to_string(), size);
4205 }
4206 }
4207 }
4208 for code in order {
4209 let size = sizes.get(&code).copied().unwrap_or_default();
4210 layout.widgets.push(widget::Placed { code, size });
4211 }
4212 let json = serde_json::to_string(&layout).unwrap_or_default();
4213 if let Err(e) = laterite_auth::store::set_user_preference(
4214 &state.db,
4215 user.user.id,
4216 widget::PREFERENCE,
4217 &json,
4218 )
4219 .await
4220 {
4221 tracing::error!(error = %e, "failed to save the dashboard layout");
4222 session.push_flash(FlashLevel::Error, t!("The dashboard could not be saved."));
4223 } else {
4224 session.push_flash(FlashLevel::Success, t!("Dashboard saved."));
4225 }
4226 Redirect::to(&state.admin_path).into_response()
4227}
4228
4229async fn dashboard_layout_reset(
4231 State(state): State<AdminState>,
4232 Extension(user): Extension<AuthenticatedUser>,
4233 Extension(session): Extension<session::SessionHandle>,
4234) -> Response {
4235 if let Err(e) =
4236 laterite_auth::store::clear_user_preference(&state.db, user.user.id, widget::PREFERENCE)
4237 .await
4238 {
4239 tracing::error!(error = %e, "failed to reset the dashboard layout");
4240 }
4241 session.push_flash(FlashLevel::Success, t!("Dashboard reset."));
4242 Redirect::to(&state.admin_path).into_response()
4243}
4244
4245struct AvailableWidgetView {
4247 code: String,
4248 label: String,
4249 description: String,
4250 shown: bool,
4251 size: String,
4252}
4253
4254fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
4257 match &item.permission {
4258 None => true,
4259 Some(p) => perms.allows(p),
4260 }
4261}
4262
4263fn visible_settings(
4267 items: &[settings::SettingsItem],
4268 perms: &PermissionSet,
4269) -> Vec<settings::SettingsItem> {
4270 items
4271 .iter()
4272 .filter(|item| operator_can_see(item, perms))
4273 .cloned()
4274 .collect()
4275}
4276
4277async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
4278 settings::index(shell)
4279}
4280
4281async fn settings_edit(
4282 state: AdminState,
4283 shell: Shell,
4284 user: AuthenticatedUser,
4285 code: String,
4286) -> Response {
4287 let items = visible_settings(&state.settings, &user.permissions);
4290 match items
4291 .iter()
4292 .find(|item| item.code == code && item.link.is_none())
4293 {
4294 Some(item) => settings::edit_form(&state, item, shell, &user).await,
4295 None => not_found(),
4296 }
4297}
4298
4299async fn settings_update(
4300 state: AdminState,
4301 shell: Shell,
4302 user: AuthenticatedUser,
4303 session: session::SessionHandle,
4304 code: String,
4305 data: HashMap<String, String>,
4306) -> Response {
4307 let items = visible_settings(&state.settings, &user.permissions);
4308 match items
4309 .iter()
4310 .find(|item| item.code == code && item.link.is_none())
4311 {
4312 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
4313 None => not_found(),
4314 }
4315}
4316
4317async fn preferences_form(
4319 State(state): State<AdminState>,
4320 Extension(shell): Extension<Shell>,
4321 Extension(user): Extension<AuthenticatedUser>,
4322 jar: CookieJar,
4323) -> Response {
4324 let account = account_security(&state, &user, &shell, &jar).await;
4325 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
4326 .into_iter()
4327 .filter(|i| i.per_operator)
4328 .map(|i| PreferenceLink {
4329 label: shell.tt(&i.label),
4330 description: shell.tt(&i.description),
4331 href: i.path(&state.admin_path),
4332 })
4333 .collect();
4334 let own = OwnMenu::load(&state, user.user.id).await;
4335 let chosen = |key: &str| -> String {
4336 own.moves
4337 .get(key)
4338 .map(|m| {
4339 if m.hierarchy == nav::TOP_HIERARCHY {
4340 "nav".to_string()
4341 } else {
4342 "settings".to_string()
4343 }
4344 })
4345 .unwrap_or_default()
4346 };
4347 let mut menu_entries: Vec<MenuEntryView> = state
4348 .nav_items
4349 .iter()
4350 .filter(|i| i.path.is_some() && !matches!(i.key.as_str(), "dashboard" | "settings"))
4351 .filter(|i| {
4352 i.permission
4353 .as_deref()
4354 .is_none_or(|p| user.permissions.allows(p))
4355 })
4356 .map(|i| MenuEntryView {
4357 key: i.key.clone(),
4358 label: shell.tt(&i.label),
4359 home: "nav",
4360 chosen: chosen(&i.key),
4361 })
4362 .collect();
4363 menu_entries.extend(
4364 visible_settings(&state.settings, &user.permissions)
4365 .into_iter()
4366 .filter(|i| !i.per_operator)
4367 .map(|i| MenuEntryView {
4368 chosen: chosen(&i.code),
4369 key: i.code,
4370 label: shell.tt(&i.label),
4371 home: "settings",
4372 }),
4373 );
4374 render(preferences_view(
4375 &shell,
4376 &user,
4377 state.timezone,
4378 &state.default_locale,
4379 &offered_locales(&state.catalogs),
4380 account,
4381 state.auth.password_policy().min_length,
4382 items,
4383 menu_entries,
4384 ))
4385}
4386
4387struct PreferenceLink {
4389 label: String,
4390 description: String,
4391 href: String,
4392}
4393
4394#[derive(Deserialize)]
4395struct RevokeSessionForm {
4396 id: String,
4397}
4398
4399async fn session_revoke(
4401 State(state): State<AdminState>,
4402 Extension(user): Extension<AuthenticatedUser>,
4403 Extension(session): Extension<session::SessionHandle>,
4404 Form(form): Form<RevokeSessionForm>,
4405) -> Response {
4406 match state.auth.revoke_session(user.user.id, &form.id).await {
4407 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
4408 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
4409 Err(e) => {
4410 tracing::error!(error = %e, "revoking a session failed");
4411 session.push_flash(
4412 FlashLevel::Error,
4413 t!("That session could not be signed out."),
4414 );
4415 }
4416 }
4417 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4418}
4419
4420#[derive(Deserialize)]
4422struct MenuOrderForm {
4423 #[serde(default)]
4424 order: String,
4425 #[serde(default)]
4427 hierarchy: String,
4428}
4429
4430async fn menu_moves_update(
4434 State(state): State<AdminState>,
4435 Extension(user): Extension<AuthenticatedUser>,
4436 Extension(session): Extension<session::SessionHandle>,
4437 Form(form): Form<HashMap<String, String>>,
4438) -> Response {
4439 let mut moves = serde_json::Map::new();
4440 for (field, place) in &form {
4441 let Some(key) = field.strip_prefix("place:") else {
4442 continue;
4443 };
4444 if key.is_empty() || key.len() > 200 || !matches!(place.as_str(), "nav" | "settings") {
4445 continue;
4446 }
4447 moves.insert(key.to_string(), serde_json::Value::String(place.clone()));
4448 }
4449 let result = if moves.is_empty() {
4450 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::MOVES_PREFERENCE)
4451 .await
4452 } else {
4453 laterite_auth::store::set_user_preference(
4454 &state.db,
4455 user.user.id,
4456 nav::MOVES_PREFERENCE,
4457 &serde_json::Value::Object(moves).to_string(),
4458 )
4459 .await
4460 };
4461 if let Err(e) = result {
4462 tracing::error!(error = %e, "storing the menu moves failed");
4463 return render_error();
4464 }
4465 session.push_flash(session::FlashLevel::Success, t!("Menu saved."));
4466 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4467}
4468
4469async fn markdown_preview(
4472 Extension(_user): Extension<AuthenticatedUser>,
4473 Form(form): Form<HashMap<String, String>>,
4474) -> Response {
4475 let text = form.get("text").map(String::as_str).unwrap_or_default();
4476 if text.len() > 1 << 20 {
4477 return (axum::http::StatusCode::PAYLOAD_TOO_LARGE, "text over 1 MiB").into_response();
4478 }
4479 Html(laterite_core::markdown::render(text)).into_response()
4480}
4481
4482async fn editor_menu(
4485 State(state): State<AdminState>,
4486 Extension(shell): Extension<Shell>,
4487 Extension(_user): Extension<AuthenticatedUser>,
4488 Query(query): Query<HashMap<String, String>>,
4489) -> Response {
4490 let Some(surface) = query.get("surface").and_then(|k| editor::Surface::parse(k)) else {
4491 return (axum::http::StatusCode::BAD_REQUEST, "unknown surface").into_response();
4492 };
4493 let registry = &state.plugin_defined;
4494 let mut menu = editor::Menu::default();
4495 for cmd in editor::commands(
4496 registry.items::<editor::EditorCommand>().into_iter(),
4497 surface,
4498 ) {
4499 let (html, text) = match &cmd.insert {
4500 editor::Insert::Html(h) => (Some(h.clone()), None),
4501 editor::Insert::Text(t) => (None, Some(t.clone())),
4502 };
4503 menu.commands.push(editor::MenuCommand {
4504 key: cmd.key.clone(),
4505 label: shell.tt(&cmd.label),
4506 html,
4507 text,
4508 });
4509 }
4510 for source in registry.items::<editor::SnippetSourceReg>() {
4511 menu.snippets.extend(source.0.list(&state.db).await);
4512 }
4513 menu.snippets.sort_by(|a, b| a.name.cmp(&b.name));
4514 axum::Json(menu).into_response()
4515}
4516
4517async fn menu_order_update(
4522 State(state): State<AdminState>,
4523 Extension(user): Extension<AuthenticatedUser>,
4524 Extension(session): Extension<session::SessionHandle>,
4525 headers: axum::http::HeaderMap,
4526 Form(form): Form<MenuOrderForm>,
4527) -> Response {
4528 let order: Vec<&str> = form
4529 .order
4530 .split(',')
4531 .map(str::trim)
4532 .filter(|k| !k.is_empty() && k.len() <= 200)
4533 .take(100)
4534 .collect();
4535 let hierarchy = if form.hierarchy.is_empty() {
4536 nav::TOP_HIERARCHY
4537 } else {
4538 form.hierarchy.as_str()
4539 };
4540 if !nav::OPERATOR_HIERARCHIES.contains(&hierarchy) {
4541 return AdminError::NotFound.into_response();
4542 }
4543 let key = nav::order_preference(hierarchy);
4544 let result = if order.is_empty() {
4545 let mut cleared = Ok(());
4547 for key in nav::OPERATOR_HIERARCHIES
4548 .iter()
4549 .map(|h| nav::order_preference(h))
4550 .chain(std::iter::once(nav::MOVES_PREFERENCE.to_string()))
4551 {
4552 if let Err(e) =
4553 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
4554 {
4555 cleared = Err(e);
4556 }
4557 }
4558 cleared
4559 } else {
4560 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &order.join(","))
4561 .await
4562 };
4563 if let Err(e) = result {
4564 tracing::error!(error = %e, "storing the menu order failed");
4565 return render_error();
4566 }
4567 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
4568 return axum::http::StatusCode::NO_CONTENT.into_response();
4569 }
4570 session.push_flash(
4571 FlashLevel::Success,
4572 if order.is_empty() {
4573 t!("The menu is back in its default order.")
4574 } else {
4575 t!("Saved.")
4576 },
4577 );
4578 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4579}
4580
4581async fn session_revoke_others(
4582 State(state): State<AdminState>,
4583 Extension(user): Extension<AuthenticatedUser>,
4584 Extension(session): Extension<session::SessionHandle>,
4585 jar: CookieJar,
4586) -> Response {
4587 let token = jar
4588 .get(SESSION_COOKIE)
4589 .map(|c| c.value().to_string())
4590 .unwrap_or_default();
4591 match state.auth.sign_out_everywhere(user.user.id, &token).await {
4592 Ok(_) => session.push_flash(
4593 FlashLevel::Success,
4594 t!("Every other session was signed out."),
4595 ),
4596 Err(e) => {
4597 tracing::error!(error = %e, "signing out other sessions failed");
4598 session.push_flash(
4599 FlashLevel::Error,
4600 t!("Those sessions could not be signed out."),
4601 );
4602 }
4603 }
4604 (
4607 jar.remove(remember_removal(&state.cookie_path)),
4608 Redirect::to(&format!("{}/preferences", state.admin_path)),
4609 )
4610 .into_response()
4611}
4612
4613#[derive(Deserialize)]
4614struct PasswordForm {
4615 current_password: String,
4616 new_password: String,
4617 confirm_password: String,
4618}
4619
4620async fn password_update(
4625 State(state): State<AdminState>,
4626 Extension(user): Extension<AuthenticatedUser>,
4627 Extension(session): Extension<session::SessionHandle>,
4628 Extension(ctx): Extension<RequestContext>,
4629 jar: CookieJar,
4630 Form(form): Form<PasswordForm>,
4631) -> Response {
4632 let back = format!("{}/preferences#password", state.admin_path);
4633 let refuse = |message: Text| {
4634 session.push_flash(FlashLevel::Error, message);
4635 Redirect::to(&back).into_response()
4636 };
4637 if form.new_password != form.confirm_password {
4638 return refuse(t!("The new passwords do not match."));
4639 }
4640 let min_length = state.auth.password_policy().min_length;
4641 if form.new_password.chars().count() < min_length {
4642 return refuse(t!(
4643 "A password needs at least {n} characters.",
4644 n = min_length as i64
4645 ));
4646 }
4647 let remembered_here = match jar.get(REMEMBER_COOKIE) {
4650 Some(cookie) => {
4651 state
4652 .auth
4653 .remember_holder(cookie.value())
4654 .await
4655 .ok()
4656 .flatten()
4657 == Some(user.user.id)
4658 }
4659 None => false,
4660 };
4661 let token = jar
4662 .get(SESSION_COOKIE)
4663 .map(|c| c.value().to_string())
4664 .unwrap_or_default();
4665 let changed = state
4666 .auth
4667 .change_own_password(
4668 user.user.id,
4669 &form.current_password,
4670 &form.new_password,
4671 &token,
4672 &ctx,
4673 )
4674 .await;
4675 match changed {
4676 Ok(()) => {}
4677 Err(laterite_auth::AuthError::InvalidCredentials) => {
4678 return refuse(t!("Your current password is not correct."));
4679 }
4680 Err(laterite_auth::AuthError::TooManyAttempts) => {
4681 return refuse(t!("Too many attempts. Try again later."));
4682 }
4683 Err(e) => {
4684 tracing::error!(error = %e, "changing a password failed");
4685 return refuse(t!("The password could not be changed."));
4686 }
4687 }
4688 session.push_flash_sticky(
4689 FlashLevel::Success,
4690 t!("Password changed. Every other device was signed out."),
4691 );
4692 let jar = if remembered_here {
4693 match state.auth.issue_remember(user.user.id).await {
4694 Ok(credential) => jar.add(remember_cookie(
4695 credential,
4696 &state.cookie_path,
4697 state.secure_cookie,
4698 )),
4699 Err(e) => {
4700 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
4701 jar.remove(remember_removal(&state.cookie_path))
4702 }
4703 }
4704 } else if jar.get(REMEMBER_COOKIE).is_some() {
4705 jar.remove(remember_removal(&state.cookie_path))
4708 } else {
4709 jar
4710 };
4711 (
4712 jar,
4713 Redirect::to(&format!("{}/preferences", state.admin_path)),
4714 )
4715 .into_response()
4716}
4717
4718#[derive(Deserialize)]
4719struct PreferencesForm {
4720 timezone: String,
4721 #[serde(default)]
4723 locale: String,
4724}
4725
4726async fn preferences_update(
4727 State(state): State<AdminState>,
4728 Extension(user): Extension<AuthenticatedUser>,
4729 Extension(session): Extension<session::SessionHandle>,
4730 Form(form): Form<PreferencesForm>,
4731) -> Response {
4732 let back = format!("{}/preferences", state.admin_path);
4733 let offered = offered_locales(&state.catalogs);
4734 let tz = form.timezone.trim();
4736 let tz_stored = if tz.is_empty() {
4737 None
4738 } else if tz.parse::<Tz>().is_ok() {
4739 Some(tz)
4740 } else {
4741 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
4742 return Redirect::to(&back).into_response();
4743 };
4744 let loc = form.locale.trim();
4745 let loc_stored = if loc.is_empty() {
4746 None
4747 } else if offered.iter().any(|l| l == loc) {
4748 Some(loc)
4749 } else {
4750 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
4751 return Redirect::to(&back).into_response();
4752 };
4753 if state
4754 .auth
4755 .set_user_timezone(user.user.id, tz_stored)
4756 .await
4757 .is_err()
4758 {
4759 return render_error();
4760 }
4761 match state.auth.set_user_locale(user.user.id, loc_stored).await {
4762 Ok(()) => {
4763 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
4764 Redirect::to(&back).into_response()
4765 }
4766 Err(_) => render_error(),
4767 }
4768}
4769
4770#[allow(clippy::too_many_arguments)]
4774fn preferences_view(
4775 shell: &Shell,
4776 user: &AuthenticatedUser,
4777 default_tz: Tz,
4778 default_locale: &str,
4779 offered: &[String],
4780 account: AccountSecurity,
4781 min_password: usize,
4782 items: Vec<PreferenceLink>,
4783 menu_entries: Vec<MenuEntryView>,
4784) -> PreferencesTemplate {
4785 let current = user.user.timezone.as_deref();
4786 let zones = TZ_VARIANTS
4787 .iter()
4788 .filter(|tz| zone_offered(tz.name()))
4789 .map(|tz| TzOption {
4790 name: tz.name().to_string(),
4791 selected: current == Some(tz.name()),
4792 })
4793 .collect();
4794 let current_locale = user.user.locale.as_deref();
4795 let locales = offered
4796 .iter()
4797 .map(|code| LocaleOption {
4798 name: locale_name(code),
4799 selected: current_locale == Some(code.as_str()),
4800 code: code.clone(),
4801 })
4802 .collect();
4803 PreferencesTemplate {
4804 shell: shell.clone(),
4805 zones,
4806 effective_tz: shell.tz.name().to_string(),
4807 default_tz: default_tz.name().to_string(),
4808 inherits: current.is_none(),
4809 locales,
4810 default_locale: locale_name(default_locale),
4811 inherits_locale: current_locale.is_none(),
4812 sessions: account.sessions,
4813 password_changed: account.password_changed,
4814 must_change: account.must_change,
4815 min_password: min_password as i64,
4816 items,
4817 menu_entries,
4818 }
4819}
4820
4821struct AccountSecurity {
4824 sessions: Vec<SessionRow>,
4825 password_changed: Option<String>,
4826 must_change: bool,
4828}
4829
4830async fn account_security(
4831 state: &AdminState,
4832 user: &AuthenticatedUser,
4833 shell: &Shell,
4834 jar: &CookieJar,
4835) -> AccountSecurity {
4836 let locale = list::date_locale(shell.locale());
4837 let password_changed = match state.auth.password_changed_at(user.user.id).await {
4838 Ok(at) => {
4839 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
4840 }
4841 Err(e) => {
4842 tracing::error!(error = %e, "reading the password change time failed");
4843 None
4844 }
4845 };
4846 AccountSecurity {
4847 sessions: session_rows(state, user, shell, jar).await,
4848 password_changed,
4849 must_change: state
4850 .auth
4851 .must_change_password(user.user.id)
4852 .await
4853 .unwrap_or(false),
4854 }
4855}
4856
4857async fn session_rows(
4860 state: &AdminState,
4861 user: &AuthenticatedUser,
4862 shell: &Shell,
4863 jar: &CookieJar,
4864) -> Vec<SessionRow> {
4865 let token = jar
4866 .get(SESSION_COOKIE)
4867 .map(|c| c.value().to_string())
4868 .unwrap_or_default();
4869 let locale = list::date_locale(shell.locale());
4870 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
4871 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
4872 };
4873 match state.auth.list_sessions(user.user.id, &token).await {
4874 Ok(sessions) => sessions
4875 .into_iter()
4876 .map(|s| SessionRow {
4877 id: s.id,
4878 started: stamp(s.created_at),
4879 last_seen: stamp(s.last_seen_at),
4880 expires: stamp(s.expires_at),
4881 device: clientip::describe(s.user_agent.as_deref())
4882 .or(s.user_agent)
4883 .unwrap_or_else(|| "-".to_string()),
4884 ip_address: s.ip_address.unwrap_or_default(),
4885 current: s.current,
4886 })
4887 .collect(),
4888 Err(e) => {
4889 tracing::error!(error = %e, "listing sessions failed");
4890 Vec::new()
4891 }
4892 }
4893}
4894
4895fn builtin_resources() -> Vec<Resource> {
4897 vec![
4898 Resource {
4899 base_path: "/users".to_string(),
4900 nav_label: "Administrators".into(),
4901 list: backend_users_list_config(),
4902 form: None,
4903 permission: Some("backend.manage_users".to_string()),
4904 import: None,
4905 palette: None,
4906 relations: Vec::new(),
4907 menu: nav::Placement::default(),
4908 },
4909 Resource {
4910 base_path: "/roles".to_string(),
4911 nav_label: "Roles".into(),
4912 list: roles_list_config(),
4913 form: None,
4916 permission: Some("backend.manage_roles".to_string()),
4917 import: None,
4918 palette: None,
4919 relations: Vec::new(),
4920 menu: nav::Placement::default(),
4921 },
4922 resource!("descriptors/audit-log.yaml"),
4923 resource!("descriptors/access-log.yaml"),
4924 resource!("descriptors/request-log.yaml"),
4925 ]
4926}
4927
4928fn builtin_settings() -> Vec<settings::SettingsItem> {
4932 vec![
4933 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
4934 .description("Manage backend administrator accounts.")
4935 .category("Users")
4936 .slot(laterite_core::arrange::slot::PEOPLE)
4937 .order(10)
4938 .icon("users")
4939 .permission("backend.manage_users")
4940 .link("/users"),
4941 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
4942 .description("Manage roles and their permissions.")
4943 .category("Users")
4944 .slot(laterite_core::arrange::slot::PEOPLE)
4945 .order(20)
4946 .icon("shield")
4947 .permission("backend.manage_roles")
4948 .link("/roles"),
4949 settings::brand::settings_item(),
4950 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
4951 .description("Enable or disable installed plugins.")
4952 .category("System")
4953 .slot(laterite_core::arrange::slot::SYSTEM)
4954 .order(10)
4955 .icon("plug")
4956 .permission(plugins::MANAGE_PERMISSION)
4957 .link("/plugins"),
4958 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
4959 .description("Review the record of administrative changes.")
4960 .category("System")
4961 .slot(laterite_core::arrange::slot::SYSTEM)
4962 .order(20)
4963 .icon("history")
4964 .permission("backend.view_audit_log")
4965 .link("/audit-log"),
4966 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
4967 .description("Review sign-ins, failures and lockouts.")
4968 .category("System")
4969 .slot(laterite_core::arrange::slot::SYSTEM)
4970 .order(30)
4971 .icon("log-in")
4972 .permission("backend.view_access_log")
4973 .link("/access-log"),
4974 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
4975 .description("Review the URLs the panel could not serve.")
4976 .category("System")
4977 .slot(laterite_core::arrange::slot::SYSTEM)
4978 .order(31)
4979 .icon("unlink")
4980 .permission("backend.view_request_log")
4981 .link("/request-log"),
4982 ]
4983}
4984
4985fn backend_users_list_config() -> list::ListConfig {
4986 list::ListConfig {
4987 entity: "backend_users".to_string(),
4988 title: "Administrators".into(),
4989 columns: vec![
4990 list::ListColumn::new("username", "Username"),
4991 list::ListColumn::new("email", "Email"),
4992 list::ListColumn::new("first_name", "First name"),
4993 list::ListColumn::new("last_name", "Last name"),
4994 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4995 list::ListColumn::new("is_active", "Active").yes_no(),
4996 list::ListColumn::new("created_at", "Created").datetime(),
4997 ],
4998 order_by: "created_at".to_string(),
4999 creatable: true,
5002 edit_base: Some("/users".to_string()),
5003 filters: vec![
5004 list::ListFilter::boolean("is_active", "Active"),
5005 list::ListFilter::boolean("is_superuser", "Superuser"),
5006 ],
5007 ..Default::default()
5011 }
5012}
5013
5014fn roles_list_config() -> list::ListConfig {
5015 list::ListConfig {
5016 entity: "backend_roles".to_string(),
5017 title: "Roles".into(),
5018 columns: vec![
5019 list::ListColumn::new("code", "Code"),
5020 list::ListColumn::new("name", "Name"),
5021 list::ListColumn::new("created_at", "Created").datetime(),
5022 ],
5023 order_by: "created_at".to_string(),
5024 edit_base: Some("/roles".to_string()),
5025 creatable: true,
5026 deletable: true,
5027 ..Default::default()
5028 }
5029}
5030
5031#[derive(Template)]
5032#[template(path = "login.html")]
5033struct LoginTemplate {
5034 base: String,
5036 asset_urls: Arc<AssetUrls>,
5038 brand: String,
5039 look: Arc<settings::brand::Brand>,
5040 skin: Arc<skin::Active>,
5041 error: Option<String>,
5042 notice: Option<String>,
5044 i18n: Translator,
5047}
5048
5049fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
5052 match urls.get(key) {
5053 Some(path) => format!("{base}/assets/{path}"),
5054 None => {
5055 debug_assert!(false, "asset key `{key}` is not registered");
5056 format!("{base}/assets/{key}")
5057 }
5058 }
5059}
5060
5061impl LoginTemplate {
5062 fn asset(&self, key: &str) -> String {
5063 preauth_asset(&self.asset_urls, &self.base, key)
5064 }
5065 fn t(&self, source: &str) -> String {
5066 self.i18n.t(&Text::dynamic(source))
5067 }
5068 fn locale(&self) -> &str {
5069 self.i18n.locale()
5070 }
5071}
5072
5073#[derive(Template)]
5074#[template(path = "dashboard.html")]
5075struct DashboardTemplate {
5076 shell: Shell,
5077 widgets: Vec<String>,
5079 available: Vec<AvailableWidgetView>,
5080}
5081
5082#[derive(Template)]
5083#[template(path = "setup.html")]
5084struct SetupTemplate {
5085 base: String,
5087 asset_urls: Arc<AssetUrls>,
5089 brand: String,
5090 look: Arc<settings::brand::Brand>,
5091 skin: Arc<skin::Active>,
5092 zones: Vec<TzOption>,
5093 error: Option<String>,
5094 i18n: Translator,
5096 min_password: i64,
5098}
5099
5100impl SetupTemplate {
5101 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
5104 let mut text = Text::dynamic(source);
5105 for (name, value) in args {
5106 text = text.arg(*name, *value);
5107 }
5108 self.i18n.t(&text)
5109 }
5110
5111 fn asset(&self, key: &str) -> String {
5112 preauth_asset(&self.asset_urls, &self.base, key)
5113 }
5114 fn t(&self, source: &str) -> String {
5115 self.i18n.t(&Text::dynamic(source))
5116 }
5117 fn locale(&self) -> &str {
5118 self.i18n.locale()
5119 }
5120}
5121
5122#[derive(Template)]
5123#[template(path = "preferences.html")]
5124struct PreferencesTemplate {
5125 shell: Shell,
5126 zones: Vec<TzOption>,
5127 effective_tz: String,
5129 default_tz: String,
5131 inherits: bool,
5133 locales: Vec<LocaleOption>,
5134 default_locale: String,
5136 inherits_locale: bool,
5138 sessions: Vec<SessionRow>,
5140 password_changed: Option<String>,
5142 must_change: bool,
5144 min_password: i64,
5146 items: Vec<PreferenceLink>,
5148 menu_entries: Vec<MenuEntryView>,
5150}
5151
5152struct MenuEntryView {
5155 key: String,
5156 label: String,
5157 home: &'static str,
5159 chosen: String,
5161}
5162
5163struct SessionRow {
5165 id: String,
5166 started: String,
5167 last_seen: String,
5168 expires: String,
5169 device: String,
5172 ip_address: String,
5174 current: bool,
5175}
5176
5177struct TzOption {
5178 name: String,
5179 selected: bool,
5180}
5181
5182struct LocaleOption {
5183 code: String,
5185 name: String,
5187 selected: bool,
5188}
5189
5190#[derive(Clone)]
5192struct CrumbView {
5193 label: String,
5194 href: String,
5195}
5196
5197#[derive(Clone)]
5198struct NavView {
5199 key: String,
5201 label: String,
5202 path: String,
5203 active: bool,
5204 icon: String,
5207}
5208
5209pub(crate) fn render<T: Template>(template: T) -> Response {
5211 match template.render() {
5212 Ok(html) => Html(html).into_response(),
5213 Err(e) => AdminError::from(e).into_response(),
5215 }
5216}
5217
5218pub(crate) fn render_error() -> Response {
5219 error::masked_500()
5220}
5221
5222pub(crate) fn not_found() -> Response {
5223 AdminError::NotFound.into_response()
5224}
5225
5226pub(crate) fn forbidden() -> Response {
5227 AdminError::Forbidden.into_response()
5228}
5229
5230#[cfg(test)]
5231mod tests {
5232 use super::*;
5233
5234 fn write_form() -> form::FormConfig {
5235 form::FormConfig {
5236 enter: Default::default(),
5237 confirm_leave: true,
5238 focus: Default::default(),
5239 entity: "widgets".to_string(),
5240 title: "Widgets".into(),
5241 base_path: "/widgets".to_string(),
5242 id_field: "id".to_string(),
5243 fields: vec![],
5244 persist: None,
5245 timestamps: false,
5246 done_path: None,
5247 }
5248 }
5249
5250 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
5251 Resource {
5252 base_path: "/widgets".to_string(),
5253 nav_label: "Widgets".into(),
5254 list: backend_users_list_config(),
5255 form,
5256 permission: permission.map(str::to_string),
5257 import: None,
5258 palette: None,
5259 relations: Vec::new(),
5260 menu: nav::Placement::default(),
5261 }
5262 }
5263
5264 #[test]
5265 #[should_panic(expected = "must declare a permission")]
5266 fn write_resource_without_permission_aborts_boot() {
5267 let resource = resource_with(Some(write_form()), None);
5270 let _ = mount_resource(
5271 &resource,
5272 &field::builtin_registry(),
5273 &list::builtin_column_registry(),
5274 &std::collections::HashSet::new(),
5275 &persist::PersisterRegistry::new(),
5276 &[],
5277 );
5278 }
5279
5280 #[test]
5282 fn the_access_log_is_read_from_its_descriptor_file() {
5283 let access = builtin_resources()
5284 .into_iter()
5285 .find(|r| r.base_path == "/access-log")
5286 .expect("the access log is registered");
5287 assert_eq!(access.list.entity, "backend_access_log");
5288 assert_eq!(
5289 access.permission.as_deref(),
5290 Some("backend.view_access_log")
5291 );
5292 assert!(access.form.is_none(), "append-only");
5293 assert_eq!(
5294 access
5295 .list
5296 .columns
5297 .iter()
5298 .map(|c| c.field.as_str())
5299 .collect::<Vec<_>>(),
5300 [
5301 "created_at",
5302 "username_attempted",
5303 "event",
5304 "ip_address",
5305 "user_agent"
5306 ]
5307 );
5308 }
5309
5310 #[test]
5313 fn the_audit_log_is_read_from_its_descriptor_file() {
5314 let audit = builtin_resources()
5315 .into_iter()
5316 .find(|r| r.base_path == "/audit-log")
5317 .expect("the audit log is registered");
5318 assert_eq!(audit.list.entity, "backend_audit_log");
5319 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
5320 assert_eq!(
5321 audit
5322 .list
5323 .columns
5324 .iter()
5325 .map(|c| c.field.as_str())
5326 .collect::<Vec<_>>(),
5327 [
5328 "created_at",
5329 "actor_username",
5330 "action",
5331 "target_type",
5332 "target_id",
5333 "target_label"
5334 ],
5335 "in the order the file writes them"
5336 );
5337 assert_eq!(audit.list.columns[0].column_type, "datetime");
5338 assert_eq!(audit.list.per_page, 50);
5339 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
5340 assert!(audit.list.exportable, "evidence may leave the panel");
5341 assert!(audit.form.is_none(), "append-only: no form");
5342 assert!(!audit.list.deletable, "and nothing removes from it");
5343 }
5344
5345 #[test]
5346 #[should_panic(expected = "uses unregistered type `nope`")]
5347 fn unregistered_column_type_aborts_boot() {
5348 let mut resource = resource_with(None, None);
5349 let mut score = list::ListColumn::new("score", "Score");
5350 score.column_type = "nope".into();
5351 resource.list.columns.push(score);
5352 let _ = mount_resource(
5353 &resource,
5354 &field::builtin_registry(),
5355 &list::builtin_column_registry(),
5356 &std::collections::HashSet::new(),
5357 &persist::PersisterRegistry::new(),
5358 &[],
5359 );
5360 }
5361
5362 #[test]
5363 fn read_only_resource_may_omit_permission() {
5364 let resource = resource_with(None, None);
5366 let _ = mount_resource(
5367 &resource,
5368 &field::builtin_registry(),
5369 &list::builtin_column_registry(),
5370 &std::collections::HashSet::new(),
5371 &persist::PersisterRegistry::new(),
5372 &[],
5373 );
5374 }
5375
5376 #[test]
5377 fn shell_localizes_through_the_translator_and_falls_back() {
5378 let shell = Shell::test();
5380 assert_eq!(shell.t("Save"), "Save");
5381 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
5382 }
5383
5384 #[test]
5385 fn shell_tf_interpolates_integer_arguments() {
5386 let shell = Shell::test();
5387 assert_eq!(
5388 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
5389 "Page 2 of 5"
5390 );
5391 }
5392
5393 #[test]
5394 fn display_tz_prefers_a_valid_operator_preference() {
5395 assert_eq!(
5396 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
5397 Tz::Asia__Kolkata
5398 );
5399 }
5400
5401 #[test]
5402 fn display_tz_falls_back_when_unset_or_invalid() {
5403 let default = Tz::Europe__London;
5404 assert_eq!(resolve_display_tz(None, default), default);
5406 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
5408 assert_eq!(resolve_display_tz(Some(""), default), default);
5409 }
5410
5411 #[test]
5412 fn accept_language_orders_by_weight_and_drops_wildcard() {
5413 assert_eq!(
5414 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
5415 vec!["en-US", "en", "kn"]
5416 );
5417 assert_eq!(
5419 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
5420 vec!["kn", "en"]
5421 );
5422 }
5423
5424 #[test]
5425 fn locale_chain_prefers_operator_then_header_then_default() {
5426 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
5428 assert_eq!(
5431 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
5432 vec!["kn", "ta", "hi", "en"]
5433 );
5434 assert_eq!(
5436 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
5437 vec!["kn", "en", "ta"]
5438 );
5439 assert_eq!(
5441 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
5442 vec!["kn", "en"]
5443 );
5444 }
5445
5446 #[test]
5447 fn locale_chain_skips_unserveable_and_defaults_to_en() {
5448 let loaded = ["hi".to_string()];
5450 assert_eq!(
5453 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
5454 vec!["en"]
5455 );
5456 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
5458 assert_eq!(
5460 resolve_locale_chain(Some("hi"), None, "en", &loaded),
5461 vec!["hi", "en"]
5462 );
5463 assert_eq!(
5465 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
5466 vec![laterite_core::PSEUDO_LOCALE, "en"]
5467 );
5468 }
5469
5470 #[test]
5471 fn a_page_with_an_aside_renders_the_fold_beside_the_body() {
5472 use askama::Template;
5473 #[derive(askama::Template)]
5474 #[template(source = "<p>{{ text }}</p>", ext = "html")]
5475 struct Para {
5476 text: &'static str,
5477 }
5478 let para = |text| html::Markup::from_template(&Para { text }).unwrap();
5479 let page = PageTemplate {
5480 title: "Spec".to_string(),
5481 back: None,
5482 actions: None,
5483 aside: Some(("Chapters".to_string(), para("one"))),
5484 body: para("body"),
5485 shell: Shell::test(),
5486 };
5487 let html = page.render().unwrap();
5488 let panel = html.find("<aside class=\"lat-aside\"").expect("aside");
5489 let body = html.find("<p>body</p>").expect("body");
5490 assert!(panel < body, "the panel comes before the body: {html}");
5491 assert!(html.contains("data-lat-widget=\"aside\""));
5492 assert!(html.contains("<summary class=\"lat-aside__summary\">Chapters</summary>"));
5493 assert!(html.contains("aria-label=\"Chapters\""));
5494 let plain = PageTemplate {
5495 title: "Spec".to_string(),
5496 back: None,
5497 actions: None,
5498 aside: None,
5499 body: para("body"),
5500 shell: Shell::test(),
5501 };
5502 assert!(!plain.render().unwrap().contains("lat-aside"));
5503 }
5504
5505 #[test]
5506 fn page_head_assets_dedupe_and_keep_order() {
5507 let head = PageHead::new("Specification")
5508 .assets(CODE_EDITOR_ASSETS)
5509 .assets(&["fields/codeeditor.js", "fields/ref-picker.js"]);
5510 assert_eq!(
5511 head.assets,
5512 vec![
5513 "vendor/codeeditor.js",
5514 "fields/codeeditor.js",
5515 "fields/codeeditor.css",
5516 "fields/ref-picker.js"
5517 ]
5518 );
5519 }
5520
5521 #[test]
5522 fn page_assets_dedupe_preserve_order_and_classify() {
5523 let mut reg = AssetRegistry::new();
5524 reg.insert(
5525 "a.js",
5526 AdminAsset {
5527 mime: "text/javascript; charset=utf-8",
5528 bytes: std::borrow::Cow::Borrowed(b"//"),
5529 },
5530 );
5531 reg.insert(
5532 "b.css",
5533 AdminAsset {
5534 mime: "text/css; charset=utf-8",
5535 bytes: std::borrow::Cow::Borrowed(b"body{}"),
5536 },
5537 );
5538 let urls = asset_urls(®);
5541 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
5542 assert_eq!(assets.len(), 2);
5543 assert_eq!(
5544 assets[0].url,
5545 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
5546 );
5547 assert!(!assets[0].css);
5548 assert_eq!(
5549 assets[1].url,
5550 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
5551 );
5552 assert!(assets[1].css);
5553 assert_ne!(assets[0].url, assets[1].url);
5556 }
5557
5558 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
5562 laterite_core::testing::connect_test(&[]).await
5563 }
5564
5565 #[tokio::test]
5566 async fn builtin_migrations_create_the_admin_tables() {
5567 let (db, _guard) = empty_db().await;
5568 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
5569 .await
5570 .unwrap();
5571 for table in ["backend_users", "settings"] {
5575 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
5576 .fetch_optional(&db.pool)
5577 .await;
5578 assert!(
5579 probe.is_ok(),
5580 "{table} should exist after builtin_migrations"
5581 );
5582 }
5583 }
5584
5585 #[tokio::test]
5586 async fn brand_setting_overrides_config_and_blank_falls_back() {
5587 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
5588 let state = AdminState {
5589 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
5590 db: db.clone(),
5591 parameters: laterite_core::parameters::Parameters::new(db.clone()),
5592 upload_extensions: Arc::new(upload_extensions(&[])),
5593 nav: Arc::new(nav::NavTree::default()),
5594 nav_items: Arc::new(Vec::new()),
5595 settings: Arc::new(Vec::new()),
5596 permissions: Arc::new(builtin_permissions()),
5597 trusted_proxies: Arc::new(Vec::new()),
5598 icons: Arc::new(laterite_core::icons::IconSet::new()),
5599 sprite_url: Arc::from(""),
5600 admin_path: Arc::from("/admin"),
5601 cookie_path: Arc::from("/admin"),
5602 storage_dir: Arc::from("storage"),
5603 signing_key: Arc::new(random_key()),
5604 order: std::collections::BTreeMap::new(),
5605 secure_cookie: false,
5606 origin: Arc::from(""),
5607 timezone: Tz::UTC,
5608 default_locale: Arc::from("en"),
5609 catalogs: Arc::new(CatalogStore::default()),
5610 app_name: "Configured Name".to_string(),
5611 brand_cache: Arc::new(RwLock::new(None)),
5612 skin: Arc::new(skin::Active::default()),
5613 field_types: Arc::new(field::builtin_registry()),
5614 column_types: Arc::new(list::builtin_column_registry()),
5615 widgets: Arc::new(widgets::builtin()),
5616 plugin_defined: Arc::new(laterite_core::Registry::new()),
5617 assets: Arc::new(builtin_assets()),
5618 asset_urls: Arc::new(asset_urls(&builtin_assets())),
5619 pickers: Arc::new(picker::PickerRegistry::new()),
5620 palette: Arc::new(Vec::new()),
5621 overrides: Arc::new(field::NoOverrides),
5622 };
5623
5624 assert_eq!(state.brand().await.name, "Configured Name");
5626
5627 settings::store::save(
5630 &db,
5631 &settings::BrandSetting {
5632 app_name: "Acme Corp".to_string(),
5633 ..Default::default()
5634 },
5635 )
5636 .await
5637 .unwrap();
5638 state.invalidate_brand();
5639 assert_eq!(state.brand().await.name, "Acme Corp");
5640
5641 settings::store::save(
5643 &db,
5644 &settings::BrandSetting {
5645 app_name: " ".to_string(),
5646 ..Default::default()
5647 },
5648 )
5649 .await
5650 .unwrap();
5651 state.invalidate_brand();
5652 assert_eq!(state.brand().await.name, "Configured Name");
5653 }
5654
5655 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
5656 settings::SettingsItem {
5657 code: code.to_string(),
5658 label: code.into(),
5659 description: String::new().into(),
5660 category: "General".into(),
5661 hint: None,
5662 confirm_leave: true,
5663 slot: laterite_core::arrange::slot::APPLICATION,
5664 order: 1,
5665 icon: None,
5666 permission: permission.map(str::to_string),
5667 link: None,
5668 fields: Vec::new(),
5669 route: None,
5670 per_operator: false,
5671 }
5672 }
5673
5674 #[test]
5675 fn settings_visibility_respects_permissions() {
5676 let items = vec![
5677 settings_item("public", None),
5678 settings_item("gated", Some("backend.manage_users")),
5679 ];
5680
5681 let none = PermissionSet::new(false, Vec::<String>::new());
5683 let codes: Vec<String> = visible_settings(&items, &none)
5684 .into_iter()
5685 .map(|i| i.code)
5686 .collect();
5687 assert_eq!(codes, ["public"]);
5688
5689 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
5691 assert_eq!(visible_settings(&items, &granted).len(), 2);
5692
5693 let superuser = PermissionSet::new(true, Vec::<String>::new());
5695 assert_eq!(visible_settings(&items, &superuser).len(), 2);
5696 }
5697
5698 #[test]
5699 fn context_sidebar_follows_settings_links() {
5700 let items = builtin_settings();
5701 let superuser = PermissionSet::new(true, Vec::<String>::new());
5702 let tr = Translator::new("en");
5703 let tree = nav::NavTree::default();
5704 let sidebar = |path: &str| {
5705 resolve_nav_context(
5706 &tree,
5707 &items,
5708 "/admin",
5709 &superuser,
5710 None,
5711 path,
5712 &tr,
5713 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
5714 &std::collections::BTreeMap::new(),
5715 None,
5716 )
5717 .1
5718 };
5719 let active_path = |path: &str| -> Option<String> {
5720 sidebar(path)
5721 .into_iter()
5722 .flat_map(|g| g.items)
5723 .find(|i| i.active)
5724 .map(|i| i.path)
5725 };
5726
5727 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
5729 assert_eq!(
5730 active_path("/admin/roles/42/edit").as_deref(),
5731 Some("/admin/roles")
5732 );
5733 assert!(!sidebar("/admin/settings").is_empty());
5735 assert_eq!(active_path("/admin/settings"), None);
5736 assert_eq!(
5741 active_path("/admin/settings/branding").as_deref(),
5742 Some("/admin/settings/branding")
5743 );
5744 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
5747 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
5748 assert!(sidebar("/admin").is_empty());
5750 }
5751}
5752
5753#[cfg(test)]
5754mod session_cookie_tests {
5755 use super::*;
5756
5757 #[test]
5761 fn the_session_cookie_never_outlives_the_browser() {
5762 let cookie = session_cookie("tok".into(), "/admin", false);
5763 assert_eq!(cookie.max_age(), None);
5764 assert!(cookie.http_only().unwrap_or(false));
5765 assert_eq!(cookie.path(), Some("/admin"));
5766 }
5767
5768 #[test]
5771 fn the_remember_cookie_carries_its_own_lifetime() {
5772 let credential = laterite_auth::RememberCredential {
5773 cookie: "sel:ver".into(),
5774 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
5775 };
5776 let cookie = remember_cookie(credential, "/admin", true);
5777 let age = cookie.max_age().expect("a remember cookie must persist");
5778 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
5780 assert!(cookie.http_only().unwrap_or(false));
5781 assert!(cookie.secure().unwrap_or(false));
5782 }
5783
5784 #[test]
5786 fn the_removal_matches_the_cookie_it_clears() {
5787 let removal = remember_removal("/admin");
5788 assert_eq!(removal.name(), REMEMBER_COOKIE);
5789 assert_eq!(removal.path(), Some("/admin"));
5790 }
5791}