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