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, RwLock};
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 brand_cache: Arc<RwLock<Option<Arc<settings::brand::Brand>>>>,
277 skin: Arc<skin::Active>,
279 asset_urls: Arc<AssetUrls>,
281 field_types: Arc<field::FieldRegistry>,
284 column_types: Arc<list::ColumnRegistry>,
287 widgets: Arc<Vec<widget::WidgetReg>>,
290 plugin_defined: Arc<laterite_core::Registry>,
293 assets: Arc<AssetRegistry>,
295 pickers: Arc<picker::PickerRegistry>,
298 palette: Arc<Vec<palette::RecordSource>>,
301 overrides: Arc<dyn field::OverrideResolver>,
305}
306
307impl AdminState {
308 #[cfg(test)]
309 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
310 Self {
311 auth,
312 parameters: laterite_core::parameters::Parameters::new(db.clone()),
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 brand_cache: Arc::new(RwLock::new(None)),
334 skin: Arc::new(skin::Active::default()),
335 field_types: Arc::new(field::builtin_registry()),
336 column_types: Arc::new(list::builtin_column_registry()),
337 widgets: Arc::new(widgets::builtin()),
338 plugin_defined: Arc::new(laterite_core::Registry::new()),
339 assets: Arc::new(builtin_assets()),
340 asset_urls: Arc::new(asset_urls(&builtin_assets())),
341 pickers: Arc::new(picker::PickerRegistry::new()),
342 palette: Arc::new(Vec::new()),
343 overrides: Arc::new(field::NoOverrides),
344 }
345 }
346
347 async fn brand(&self) -> Arc<settings::brand::Brand> {
351 {
352 let cached = self.brand_cache.read().unwrap().clone();
353 if let Some(brand) = cached {
354 return brand;
355 }
356 }
357 let resolved = Arc::new(settings::brand::Brand::resolve(&self.db, &self.app_name).await);
358 *self.brand_cache.write().unwrap() = Some(Arc::clone(&resolved));
359 resolved
360 }
361
362 fn invalidate_brand(&self) {
364 *self.brand_cache.write().unwrap() = None;
365 }
366}
367
368#[derive(Clone)]
374#[non_exhaustive]
375pub struct AdminConfig {
376 pub secure_cookie: bool,
379 pub trusted_proxies: Vec<String>,
383 pub timezone: String,
387 pub locale: String,
391 pub app_name: String,
395 pub path: String,
399 pub origin: String,
404 pub storage_dir: String,
409 pub skin: String,
412 pub upload_extensions: Vec<String>,
415 pub order: std::collections::BTreeMap<String, Vec<String>>,
418 pub menu: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
420 pub secret: Vec<u8>,
425 pub crypt: Option<Arc<laterite_core::Crypt>>,
428}
429
430impl Default for AdminConfig {
431 fn default() -> Self {
432 Self {
433 secure_cookie: false,
434 trusted_proxies: Vec::new(),
435 timezone: "UTC".to_string(),
436 locale: "en".to_string(),
437 app_name: "Laterite".to_string(),
438 path: "/admin".to_string(),
439 origin: String::new(),
440 storage_dir: "storage".to_string(),
441 skin: "standard".to_string(),
442 upload_extensions: Vec::new(),
443 order: std::collections::BTreeMap::new(),
444 menu: std::collections::BTreeMap::new(),
445 secret: random_key(),
446 crypt: None,
447 }
448 }
449}
450
451pub(crate) fn random_key() -> Vec<u8> {
453 use rand::RngCore;
454 let mut key = vec![0u8; 32];
455 rand::thread_rng().fill_bytes(&mut key);
456 key
457}
458
459#[derive(Clone)]
468pub struct Shell {
469 pub(crate) base: String,
473 brand: String,
477 brand_look: Arc<settings::brand::Brand>,
479 skin: Arc<skin::Active>,
480 nav: Vec<NavView>,
481 full_name: String,
482 initial: String,
483 tz: Tz,
487 i18n: Translator,
491 sidebar: Vec<settings::CategoryView>,
495 crumbs: Vec<CrumbView>,
498 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
500 pub(crate) sprite_url: Arc<str>,
503 site_url: String,
507 asset_urls: Arc<AssetUrls>,
511 pub(crate) csrf_token: String,
515 pub(crate) flash: Vec<FlashLine>,
519 pub(crate) assets: Vec<PageAsset>,
523}
524
525#[derive(Template)]
527#[template(path = "page.html")]
528struct PageTemplate {
529 title: String,
530 back: Option<(String, String)>,
532 actions: Option<html::Markup>,
534 aside: Option<(String, html::Markup)>,
536 body: html::Markup,
537 shell: Shell,
538}
539
540pub struct PageHead {
551 title: Text,
552 back: Option<(Text, String)>,
553 actions: Option<html::Markup>,
554 aside: Option<(Text, html::Markup)>,
555 assets: Vec<&'static str>,
556}
557
558impl PageHead {
559 pub fn new(title: impl Into<Text>) -> Self {
560 Self {
561 title: title.into(),
562 back: None,
563 actions: None,
564 aside: None,
565 assets: Vec::new(),
566 }
567 }
568
569 pub fn assets(mut self, keys: &[&'static str]) -> Self {
573 for key in keys {
574 if !self.assets.contains(key) {
575 self.assets.push(key);
576 }
577 }
578 self
579 }
580
581 pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
583 self.back = Some((label.into(), href.into()));
584 self
585 }
586
587 pub fn actions(mut self, actions: html::Markup) -> Self {
590 self.actions = Some(actions);
591 self
592 }
593
594 pub fn aside(mut self, label: impl Into<Text>, panel: html::Markup) -> Self {
598 self.aside = Some((label.into(), panel));
599 self
600 }
601}
602
603#[derive(Clone)]
605pub(crate) struct PageAsset {
606 pub(crate) url: String,
607 pub(crate) css: bool,
608}
609
610#[derive(Clone)]
613pub(crate) struct FlashLine {
614 pub(crate) level: session::FlashLevel,
615 pub(crate) text: String,
616 pub(crate) sticky: bool,
618}
619
620impl Shell {
621 #[allow(clippy::too_many_arguments)]
622 fn new(
623 base: &str,
624 brand: Arc<settings::brand::Brand>,
625 skin: Arc<skin::Active>,
626 nav: &[nav::NavNode],
627 user: &AuthenticatedUser,
628 default_tz: Tz,
629 sidebar: Vec<settings::CategoryView>,
630 active_nav: Option<&str>,
631 csrf_token: String,
632 flash: Vec<session::Flash>,
633 i18n: Translator,
634 asset_urls: Arc<AssetUrls>,
635 site_url: String,
636 icon_set: Arc<laterite_core::icons::IconSet>,
637 sprite_url: Arc<str>,
638 ) -> Self {
639 let full_name = user.user.full_name();
640 let initial = full_name
641 .chars()
642 .next()
643 .map(|c| c.to_uppercase().to_string())
644 .unwrap_or_else(|| "?".to_string());
645 let flash = flash
648 .into_iter()
649 .map(|f| FlashLine {
650 sticky: f.sticky,
651 level: f.level,
652 text: i18n.t(&f.text),
653 })
654 .collect();
655 let nav: Vec<NavView> = nav
656 .iter()
657 .map(|n| NavView {
658 key: n.item.key.clone(),
659 label: i18n.t(&n.item.label),
660 path: nav::tab_path(n).unwrap_or_default().to_string(),
661 active: active_nav == Some(n.item.key.as_str()),
662 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
663 })
664 .collect();
665 let mut crumbs: Vec<CrumbView> = nav
666 .iter()
667 .filter(|n: &&NavView| n.active)
668 .map(|n| CrumbView {
669 label: n.label.clone(),
670 href: n.path.clone(),
671 })
672 .collect();
673 if let Some(entry) = sidebar
674 .iter()
675 .flat_map(|g| g.items.iter())
676 .find(|i| i.active)
677 {
678 crumbs.push(CrumbView {
679 label: entry.label.clone(),
680 href: entry.path.clone(),
681 });
682 }
683 Shell {
684 asset_urls,
685 icon_set,
686 sprite_url,
687 site_url,
688 base: base.to_string(),
689 brand: brand.name.clone(),
690 brand_look: brand,
691 skin,
692 nav,
693 crumbs,
694 full_name,
695 initial,
696 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
697 i18n,
698 sidebar,
699 csrf_token,
700 flash,
701 assets: Vec::new(),
702 }
703 }
704
705 pub(crate) fn site_url(&self) -> &str {
707 &self.site_url
708 }
709
710 pub(crate) fn sidebar_anchored(&self) -> bool {
713 self.nav.iter().any(|n| n.active)
714 }
715
716 pub fn icon(&self, name: &str) -> String {
720 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
721 }
722
723 pub(crate) fn asset(&self, key: &str) -> String {
728 match self.asset_urls.get(key) {
729 Some(path) => format!("{}/assets/{path}", self.base),
730 None => {
731 debug_assert!(false, "asset key `{key}` is not registered");
732 format!("{}/assets/{key}", self.base)
733 }
734 }
735 }
736
737 pub fn t(&self, source: &str) -> String {
740 self.i18n.t(&Text::dynamic(source))
741 }
742
743 pub fn tt(&self, text: &Text) -> String {
746 self.i18n.t(text)
747 }
748
749 pub fn i18n(&self) -> &Translator {
752 &self.i18n
753 }
754
755 pub fn locale(&self) -> &str {
757 self.i18n.locale()
758 }
759
760 pub fn base(&self) -> &str {
762 &self.base
763 }
764
765 pub fn csrf_token(&self) -> &str {
769 &self.csrf_token
770 }
771
772 pub fn brand_logo(&self) -> Option<&str> {
774 self.brand_look.logo_url.as_deref()
775 }
776 pub fn brand_favicon(&self) -> Option<&str> {
778 self.brand_look.favicon_url.as_deref()
779 }
780 pub fn brand_css(&self) -> &str {
782 &self.brand_look.css
783 }
784 pub fn skin_code(&self) -> &str {
787 &self.skin.code
788 }
789
790 pub fn skin_css(&self) -> Option<&str> {
792 self.skin.stylesheet.as_deref()
793 }
794
795 pub fn mode_default(&self) -> &str {
796 &self.brand_look.mode
797 }
798
799 pub fn datetime(&self, utc: &str) -> String {
802 match chrono::DateTime::parse_from_rfc3339(utc) {
803 Ok(at) => at
804 .with_timezone(&self.tz)
805 .format("%Y-%m-%d %H:%M")
806 .to_string(),
807 Err(_) => utc.to_string(),
808 }
809 }
810
811 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
815 self.page_with(PageHead::new(title.clone()), body)
816 }
817
818 pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
821 let mut shell = self.clone();
822 for key in &head.assets {
823 let url = shell.asset(key);
824 if !shell.assets.iter().any(|a| a.url == url) {
825 shell.assets.push(PageAsset {
826 url,
827 css: key.ends_with(".css"),
828 });
829 }
830 }
831 render(PageTemplate {
832 title: self.tt(&head.title),
833 back: head.back.map(|(label, href)| (self.tt(&label), href)),
834 actions: head.actions,
835 aside: head.aside.map(|(label, panel)| (self.tt(&label), panel)),
836 body,
837 shell,
838 })
839 }
840
841 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
845 let mut text = Text::dynamic(source);
846 for (name, value) in args {
847 text = text.arg(*name, *value);
848 }
849 self.i18n.t(&text)
850 }
851
852 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
855 let mut text = Text::dynamic(source);
856 for (name, value) in args {
857 text = text.arg(*name, *value);
858 }
859 self.i18n.t(&text)
860 }
861
862 #[cfg(test)]
863 pub(crate) fn test() -> Self {
864 Shell {
865 asset_urls: Arc::new(asset_urls(&builtin_assets())),
866 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
867 sprite_url: Arc::from("/admin/assets/icons.svg"),
868 site_url: "http://localhost".to_string(),
869 base: "/admin".to_string(),
870 brand: "Laterite".to_string(),
871 brand_look: Arc::new(settings::brand::Brand::of_name("Laterite")),
872 skin: Arc::new(skin::Active::default()),
873 nav: Vec::new(),
874 full_name: "Test Operator".to_string(),
875 initial: "T".to_string(),
876 tz: Tz::UTC,
877 i18n: Translator::new("en"),
878 sidebar: Vec::new(),
879 crumbs: Vec::new(),
880 csrf_token: "test-csrf-token".to_string(),
881 flash: Vec::new(),
882 assets: Vec::new(),
883 }
884 }
885}
886
887fn settings_context(
894 visible: &[settings::SettingsItem],
895 admin_path: &str,
896 path: &str,
897) -> (bool, Option<String>) {
898 if path == format!("{admin_path}/settings") {
899 return (true, None);
900 }
901 let own = visible
907 .iter()
908 .filter(|i| i.link.is_none())
909 .find(|i| path == format!("{admin_path}{}", i.route()))
910 .map(|i| i.code.clone());
911 if own.is_some() {
912 return (true, own);
913 }
914 let linked = visible
915 .iter()
916 .filter_map(|i| {
917 i.link
918 .as_deref()
919 .map(|link| (format!("{admin_path}{link}"), &i.code))
920 })
921 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
922 .max_by_key(|(link, _)| link.len())
923 .map(|(_, code)| code.clone());
924 (linked.is_some(), linked)
925}
926
927#[allow(clippy::too_many_arguments)]
933#[derive(Default)]
936struct OwnMenu {
937 nav_order: Option<String>,
938 settings_order: Option<String>,
939 moves: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
940}
941
942impl OwnMenu {
943 async fn load(state: &AdminState, user_id: i64) -> Self {
944 let read = |key: String| async move {
945 laterite_auth::store::user_preference(&state.db, user_id, &key)
946 .await
947 .unwrap_or_default()
948 };
949 let nav_order = read(nav::order_preference(nav::TOP_HIERARCHY)).await;
950 let settings_order = read(nav::order_preference(settings::HIERARCHY)).await;
951 let moves = read(nav::MOVES_PREFERENCE.to_string())
952 .await
953 .map(|json| nav::operator_moves(&json))
954 .unwrap_or_default();
955 Self {
956 nav_order,
957 settings_order,
958 moves,
959 }
960 }
961}
962
963fn own_tree(
966 state: &AdminState,
967 own: &OwnMenu,
968) -> (Arc<nav::NavTree>, Arc<Vec<settings::SettingsItem>>) {
969 if own.moves.is_empty() {
970 return (Arc::clone(&state.nav), Arc::clone(&state.settings));
971 }
972 let mut items: Vec<nav::NavItem> = state.nav_items.as_ref().clone();
973 let mut settings: Vec<settings::SettingsItem> = state.settings.as_ref().clone();
974 nav::apply_moves(&mut items, &mut settings, &own.moves);
975 match nav::NavTree::build_with(items, &state.admin_path, &state.order) {
976 Ok(tree) => (Arc::new(tree), Arc::new(settings)),
977 Err(e) => {
978 tracing::warn!(error = %e, "an operator's menu moves could not be applied");
979 (Arc::clone(&state.nav), Arc::clone(&state.settings))
980 }
981 }
982}
983
984#[allow(clippy::too_many_arguments)]
985fn resolve_nav_context(
986 nav: &nav::NavTree,
987 items: &[settings::SettingsItem],
988 admin_path: &str,
989 perms: &PermissionSet,
990 own_order: Option<&str>,
991 path: &str,
992 tr: &Translator,
993 icons: icons::Icons<'_>,
994 order: &std::collections::BTreeMap<String, Vec<String>>,
995 own_settings_order: Option<&str>,
996) -> (
997 Vec<nav::NavNode>,
998 Vec<settings::CategoryView>,
999 Option<String>,
1000) {
1001 let mut visible_nav = nav.visible(&|code| perms.allows(code));
1002 if let Some(order) = own_order {
1003 nav::apply_order(&mut visible_nav, order);
1004 }
1005 let own_sequence = own_settings_order.map(laterite_core::arrange::parse_sequence);
1006 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
1008 .into_iter()
1009 .filter(|i| !i.per_operator)
1010 .collect();
1011 let (in_context, active) = settings_context(&visible, admin_path, path);
1012 if in_context {
1013 let sidebar = settings::sidebar_groups(
1014 &visible,
1015 admin_path,
1016 active.as_deref(),
1017 tr,
1018 icons,
1019 order.get(settings::HIERARCHY).map(Vec::as_slice),
1020 own_sequence.as_deref(),
1021 );
1022 return (visible_nav, sidebar, Some("settings".to_string()));
1023 }
1024 let active_top = nav::active_top(&visible_nav, admin_path, path);
1025 let sidebar = active_top
1026 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
1027 .unwrap_or_default();
1028 let active_key = active_top.map(|node| node.item.key.clone());
1029 (visible_nav, sidebar, active_key)
1030}
1031
1032fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
1036 preference
1037 .and_then(|name| name.parse::<Tz>().ok())
1038 .unwrap_or(default_tz)
1039}
1040
1041fn zone_offered(name: &str) -> bool {
1043 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
1044}
1045
1046fn base_lang(tag: &str) -> String {
1049 tag.split(['-', '_'])
1050 .next()
1051 .unwrap_or(tag)
1052 .to_ascii_lowercase()
1053}
1054
1055fn is_serveable(base: &str, serveable: &[String]) -> bool {
1061 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
1062}
1063
1064fn default_locale(configured: &str, serveable: &[String]) -> String {
1067 let base = base_lang(configured);
1068 if is_serveable(&base, serveable) {
1069 base
1070 } else {
1071 "en".to_string()
1072 }
1073}
1074
1075fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
1079 let mut out = vec!["en".to_string()];
1080 out.extend(catalogs.locales());
1081 out
1082}
1083
1084fn locale_name(code: &str) -> String {
1089 if code == "en" {
1090 "English".to_string()
1091 } else {
1092 code.to_string()
1093 }
1094}
1095
1096fn parse_accept_language(header: &str) -> Vec<String> {
1099 let mut items: Vec<(f32, String)> = header
1100 .split(',')
1101 .filter_map(|part| {
1102 let mut bits = part.split(';');
1103 let tag = bits.next()?.trim();
1104 if tag.is_empty() || tag == "*" {
1105 return None;
1106 }
1107 let q = bits
1108 .find_map(|p| p.trim().strip_prefix("q="))
1109 .and_then(|q| q.parse::<f32>().ok())
1110 .unwrap_or(1.0);
1111 Some((q, tag.to_string()))
1112 })
1113 .collect();
1114 items.sort_by(|a, b| b.0.total_cmp(&a.0));
1116 items.into_iter().map(|(_, tag)| tag).collect()
1117}
1118
1119fn resolve_locale_chain(
1125 preference: Option<&str>,
1126 accept_language: Option<&str>,
1127 default: &str,
1128 serveable: &[String],
1129) -> Vec<String> {
1130 let mut chain: Vec<String> = Vec::new();
1131 let consider = |tag: &str, chain: &mut Vec<String>| {
1132 let base = base_lang(tag);
1133 if is_serveable(&base, serveable) && !chain.contains(&base) {
1134 chain.push(base);
1135 }
1136 };
1137 if let Some(pref) = preference {
1138 consider(pref, &mut chain);
1139 }
1140 if let Some(header) = accept_language {
1141 for tag in parse_accept_language(header) {
1142 consider(&tag, &mut chain);
1143 }
1144 }
1145 consider(default, &mut chain);
1146 consider("en", &mut chain);
1149 chain
1150}
1151
1152fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
1156 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
1157 let serveable = state.catalogs.locales();
1158 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
1159 Translator::with_chain(chain, state.catalogs.clone())
1160}
1161
1162#[derive(Serialize)]
1170#[non_exhaustive]
1171pub struct Resource {
1172 pub base_path: String,
1178 pub nav_label: Text,
1180 pub list: list::ListConfig,
1181 pub form: Option<form::FormConfig>,
1182 pub permission: Option<String>,
1189 pub import: Option<ImportConfig>,
1192 pub palette: Option<String>,
1195 pub relations: Vec<relation::RelationConfig>,
1198 pub menu: nav::Placement,
1201}
1202
1203#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1206#[non_exhaustive]
1207pub struct ImportConfig {
1208 #[serde(default)]
1211 pub permission: Option<String>,
1212}
1213
1214impl ImportConfig {
1215 pub fn new() -> Self {
1216 Self::default()
1217 }
1218
1219 pub fn permission(mut self, code: impl Into<String>) -> Self {
1222 self.permission = Some(code.into());
1223 self
1224 }
1225}
1226
1227#[derive(Clone, Serialize)]
1232#[non_exhaustive]
1233pub struct Permission {
1234 pub code: String,
1235 pub label: Text,
1237 pub group: Text,
1238 pub default_roles: Vec<String>,
1241 pub renamed_from: Vec<String>,
1244}
1245
1246pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
1247
1248impl Permission {
1249 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
1252 Self {
1253 code: code.into(),
1254 label: label.into(),
1255 group: group.into(),
1256 default_roles: Vec::new(),
1257 renamed_from: Vec::new(),
1258 }
1259 }
1260
1261 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
1267 where
1268 I: IntoIterator<Item = S>,
1269 S: Into<String>,
1270 {
1271 self.renamed_from = codes.into_iter().map(Into::into).collect();
1272 self
1273 }
1274
1275 pub fn roles<I, S>(mut self, roles: I) -> Self
1278 where
1279 I: IntoIterator<Item = S>,
1280 S: Into<String>,
1281 {
1282 self.default_roles = roles.into_iter().map(Into::into).collect();
1283 self
1284 }
1285}
1286
1287pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1293 let permissions: Vec<Permission> = builtin_permissions()
1294 .into_iter()
1295 .chain(contributed.iter().cloned())
1296 .collect();
1297 let mut out: Vec<(String, String)> = Vec::new();
1298 for permission in &permissions {
1299 for old in &permission.renamed_from {
1300 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1301 panic!(
1302 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1303 permission.code
1304 );
1305 }
1306 if permissions.iter().any(|p| &p.code == old) {
1307 panic!(
1308 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1309 permission.code
1310 );
1311 }
1312 out.push((old.clone(), permission.code.clone()));
1313 }
1314 }
1315 out
1316}
1317
1318pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1324 let permissions: Vec<Permission> = builtin_permissions()
1327 .into_iter()
1328 .chain(contributed.iter().cloned())
1329 .collect();
1330 let held = |role: &str| -> Vec<String> {
1331 permissions
1332 .iter()
1333 .filter(|p| {
1334 if p.default_roles.is_empty() {
1335 role == ROLE_ADMIN
1336 } else {
1337 p.default_roles.iter().any(|r| r == role)
1338 }
1339 })
1340 .map(|p| p.code.clone())
1341 .collect()
1342 };
1343 vec![
1344 laterite_auth::store::SystemRole {
1345 code: ROLE_ADMIN,
1346 name: "Administrator",
1347 description: "Administers the panel itself: operators, roles and settings.",
1348 permissions: held(ROLE_ADMIN),
1349 },
1350 laterite_auth::store::SystemRole {
1351 code: ROLE_EDITOR,
1352 name: "Editor",
1353 description: "Works with the content the application declares.",
1354 permissions: held(ROLE_EDITOR),
1355 },
1356 ]
1357}
1358
1359impl Resource {
1360 pub fn new(
1365 base_path: impl Into<String>,
1366 nav_label: impl Into<Text>,
1367 list: list::ListConfig,
1368 ) -> Self {
1369 Self {
1370 base_path: base_path.into(),
1371 nav_label: nav_label.into(),
1372 list,
1373 form: None,
1374 permission: None,
1375 import: None,
1376 palette: None,
1377 relations: Vec::new(),
1378 menu: nav::Placement::default(),
1379 }
1380 }
1381
1382 pub fn palette(mut self, source: impl Into<String>) -> Self {
1385 self.palette = Some(source.into());
1386 self
1387 }
1388
1389 pub fn menu(mut self, placement: nav::Placement) -> Self {
1391 self.menu = placement;
1392 self
1393 }
1394
1395 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1397 self.relations.push(relation);
1398 self
1399 }
1400
1401 pub fn import(mut self, import: ImportConfig) -> Self {
1403 self.import = Some(import);
1404 self
1405 }
1406
1407 pub fn form(mut self, form: form::FormConfig) -> Self {
1410 self.form = Some(form);
1411 self
1412 }
1413
1414 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1416 self.permission = Some(permission.into());
1417 self
1418 }
1419}
1420
1421struct SystemRoleGuard;
1427
1428#[laterite_core::strata::async_trait]
1429impl laterite_core::ModelListener for SystemRoleGuard {
1430 async fn before_delete(
1431 &self,
1432 cx: &mut laterite_core::SaveCx<'_>,
1433 rec: &laterite_core::Record,
1434 ) -> Result<(), Text> {
1435 let Some(id) = rec.id() else {
1436 return Ok(());
1437 };
1438 let backend = cx.backend();
1440 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1441 Ok(true) => Err(t!(
1442 "The framework maintains this role; it cannot be deleted."
1443 )),
1444 Err(_) => Err(t!(
1446 "Could not check whether this role is maintained by the framework."
1447 )),
1448 Ok(false) => Ok(()),
1449 }
1450 }
1451}
1452
1453fn builtin_permissions() -> Vec<Permission> {
1456 let mut out = vec![
1459 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1460 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1461 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1462 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1463 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1464 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1465 Permission::new(
1466 "backend.view_request_log",
1467 "View the request log",
1468 "Backend",
1469 ),
1470 ];
1471 out.extend(medialib::permissions());
1472 out
1473}
1474
1475pub fn descriptor_sources() -> Vec<String> {
1481 let mut out = Vec::new();
1482 out.extend(laterite_core::collect_sources(&builtin_resources()));
1483 out.extend(laterite_core::collect_sources(&builtin_settings()));
1484 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1485 out.sort();
1486 out.dedup();
1487 out
1488}
1489
1490pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1505 builtin_modules().iter().map(|m| m.migrations()).collect()
1506}
1507
1508pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1512 vec![
1513 Box::new(laterite_auth::AuthModule),
1514 Box::new(settings::SettingsModule),
1515 Box::new(laterite_core::parameters::ParametersModule),
1516 Box::new(laterite_core::crypt::CryptModule),
1517 Box::new(requestlog::RequestLogModule),
1518 Box::new(plugins::PluginsModule),
1519 Box::new(laterite_media::MediaModule),
1520 Box::new(medialib::MediaLibraryModule),
1521 Box::new(import::ImportModule),
1522 ]
1523}
1524
1525#[derive(Default)]
1549pub struct Contributions {
1550 pub resources: Vec<Resource>,
1552 pub settings: Vec<settings::SettingsItem>,
1554 pub permissions: Vec<Permission>,
1556 pub picker_sources: Vec<picker::PickerSourceReg>,
1558 pub persisters: Vec<persist::PersisterReg>,
1560 pub listeners: Vec<laterite_core::ModelListenerReg>,
1562 pub field_types: Vec<field::FieldTypeReg>,
1566 pub column_types: Vec<list::ColumnTypeReg>,
1567 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1570 pub screens: Vec<routes::ScreenReg>,
1572 pub public_routes: Vec<routes::PublicRouteReg>,
1574 pub nav: Vec<nav::NavItem>,
1577 pub resource_hooks: Vec<ResourceHook>,
1580 pub widgets: Vec<widget::WidgetReg>,
1582 pub skins: Vec<skin::Skin>,
1584 pub assets: Vec<AssetReg>,
1586 pub plugin_defined: Arc<laterite_core::Registry>,
1591}
1592
1593pub fn router(
1594 auth: AuthService,
1595 db: Db,
1596 contributions: Contributions,
1597 config: AdminConfig,
1598 catalogs: Arc<CatalogStore>,
1599) -> Router {
1600 let Contributions {
1601 resources: app_resources,
1602 settings: app_settings,
1603 permissions: app_permissions,
1604 picker_sources: app_picker_sources,
1605 persisters: app_persisters,
1606 listeners: app_listeners,
1607 field_types: app_field_types,
1608 column_types: app_column_types,
1609 widgets: app_widgets,
1610 skins: app_skins,
1611 assets: app_assets,
1612 icons: app_icons,
1613 screens: app_screens,
1614 public_routes: app_public,
1615 nav: app_nav,
1616 resource_hooks,
1617 plugin_defined,
1618 } = contributions;
1619 let mut app_screens = app_screens;
1622 app_screens.push(medialib::screen());
1623 let mut app_public = app_public;
1624 app_public.push(medialib::serve_route());
1625 let admin_path = normalize_path(&config.path);
1626
1627 let mut resources = builtin_resources();
1628 let mut app_resources = app_resources;
1629 for hook in resource_hooks {
1632 let found = resources
1633 .iter_mut()
1634 .chain(app_resources.iter_mut())
1635 .find(|r| r.base_path == hook.target);
1636 match found {
1637 Some(resource) => {
1638 let changed = hook.apply(std::mem::replace(
1639 resource,
1640 Resource::new("", "", list::ListConfig::default()),
1641 ));
1642 *resource = changed;
1643 }
1644 None => panic!(
1645 "a resource hook targets `{}`, which no module declared",
1646 hook.target
1647 ),
1648 }
1649 }
1650 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1654 prefix_resource(&admin_path, resource);
1655 }
1656 for resource in app_resources.iter_mut() {
1660 if let Some(import) = &resource.import {
1661 let permission = import
1662 .permission
1663 .clone()
1664 .or_else(|| resource.permission.clone())
1665 .unwrap_or_else(|| {
1666 panic!(
1667 "admin resource `{}` declares `import` but no permission; importing \
1668 writes records, so it must be gated",
1669 resource.base_path
1670 )
1671 });
1672 resource.list.import = Some(routes::feature_link(
1673 &resource.base_path,
1674 "import",
1675 &permission,
1676 ));
1677 }
1678 }
1679
1680 let mut settings = builtin_settings();
1681 settings.extend(app_settings);
1682 let mut app_listeners = app_listeners;
1685 app_listeners.insert(
1686 0,
1687 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1688 );
1689
1690 let mut permissions = builtin_permissions();
1691 permissions.extend(app_permissions);
1692 let permission_codes: std::collections::HashSet<String> =
1694 permissions.iter().map(|p| p.code.clone()).collect();
1695
1696 let mut nav_items = vec![
1702 nav::NavItem::new("dashboard", "Dashboard", "")
1703 .icon("layout-dashboard")
1704 .slot(laterite_core::arrange::slot::HOME),
1705 nav::NavItem::new("settings", "Settings", "/settings")
1706 .icon("settings")
1707 .slot(laterite_core::arrange::slot::SYSTEM),
1708 ];
1709 nav_items.extend(app_nav);
1710 for resource in &app_resources {
1711 if resource.menu.hidden {
1712 continue;
1713 }
1714 let unprefixed = resource
1715 .base_path
1716 .strip_prefix(admin_path.as_str())
1717 .unwrap_or(&resource.base_path)
1718 .to_string();
1719 let mut item = nav::NavItem::new(
1720 format!("resource:{unprefixed}"),
1721 resource.nav_label.clone(),
1722 unprefixed,
1723 )
1724 .slot(resource.menu.slot)
1725 .order(resource.menu.order);
1726 item.icon = resource.menu.icon.clone();
1727 item.permission = resource.permission.clone();
1728 item.under = resource.menu.under.clone();
1729 item.after = resource.menu.after.clone();
1730 item.before = resource.menu.before.clone();
1731 nav_items.push(item);
1732 }
1733 for reg in &app_screens {
1734 let Some(label) = ®.nav_label else {
1735 continue;
1736 };
1737 if reg.menu.hidden {
1738 continue;
1739 }
1740 let mut item = nav::NavItem::new(
1741 format!("screen:{}", reg.base_path),
1742 label.clone(),
1743 reg.base_path.clone(),
1744 )
1745 .permission(reg.permission.clone())
1746 .slot(reg.menu.slot)
1747 .order(reg.menu.order);
1748 item.icon = reg.menu.icon.clone();
1749 item.under = reg.menu.under.clone();
1750 item.after = reg.menu.after.clone();
1751 item.before = reg.menu.before.clone();
1752 nav_items.push(item);
1753 }
1754 nav::apply_moves(&mut nav_items, &mut settings, &config.menu);
1757 let nav = nav::NavTree::build_with(nav_items.clone(), &admin_path, &config.order)
1758 .unwrap_or_else(|e| panic!("admin menu: {e}"));
1759 nav.log_arrangement();
1760 settings::log_arrangement(
1761 &settings,
1762 config.order.get(settings::HIERARCHY).map(Vec::as_slice),
1763 );
1764 let palette_sources: Vec<palette::RecordSource> = app_resources
1767 .iter()
1768 .filter_map(|r| {
1769 r.palette.as_ref().map(|source| palette::RecordSource {
1770 label: r.nav_label.clone(),
1771 source: source.clone(),
1772 base_path: r.base_path.clone(),
1773 permission: r.permission.clone(),
1774 })
1775 })
1776 .collect();
1777 resources.extend(app_resources);
1778
1779 let app_name = if config.app_name.trim().is_empty() {
1780 "Laterite".to_string()
1781 } else {
1782 config.app_name.clone()
1783 };
1784
1785 let mut pickers = picker::PickerRegistry::new();
1788 for reg in app_picker_sources {
1789 let name = reg.name.clone();
1790 if !field::is_name(&name, true) {
1791 panic!("invalid picker source name `{name}`");
1792 }
1793 if pickers.insert(name.clone(), reg).is_some() {
1794 panic!("duplicate picker source `{name}`");
1795 }
1796 }
1797
1798 for source in &palette_sources {
1802 if !pickers.contains_key(&source.source) {
1803 panic!(
1804 "admin resource `{}` names palette source `{}`, which no module registered",
1805 source.base_path, source.source
1806 );
1807 }
1808 }
1809 let pickers = Arc::new(pickers);
1810 let crypt: Arc<laterite_core::Crypt> = config.crypt.clone().unwrap_or_else(|| {
1813 Arc::new(laterite_core::Crypt::new(
1814 laterite_core::MasterKey::derived(0, &config.secret),
1815 Vec::new(),
1816 ))
1817 });
1818 let mut field_types = field::builtin_registry();
1819 field_types.insert(
1820 "sensitive".to_string(),
1821 Arc::new(field::SensitiveField::new(crypt.clone())),
1822 );
1823 field_types.insert(
1824 "reference".to_string(),
1825 Arc::new(field::RefPickerField::new(pickers.clone())),
1826 );
1827
1828 let mut persisters = persist::PersisterRegistry::new();
1831 for reg in app_persisters {
1832 let name = reg.name.clone();
1833 if !field::is_name(&name, true) {
1834 panic!("invalid persister name `{name}`");
1835 }
1836 if persisters.insert(name.clone(), reg.persister).is_some() {
1837 panic!("duplicate persister `{name}`");
1838 }
1839 }
1840
1841 for reg in app_field_types {
1847 let name = reg.name().to_string();
1848 if !field::is_name(&name, true) {
1849 panic!("invalid field type name `{name}`");
1850 }
1851 if field_types.insert(name.clone(), reg.field_type).is_some() {
1852 panic!("duplicate field type `{name}`");
1853 }
1854 }
1855
1856 let mut icon_set = laterite_core::icons::IconSet::new();
1860 for (owner, reg) in app_icons {
1861 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1862 panic!("module `{owner}` contributed an invalid icon: {e}");
1863 }
1864 }
1865
1866 let mut assets = builtin_assets();
1869 assets.insert(
1870 ICON_SPRITE,
1871 AdminAsset {
1872 mime: "image/svg+xml",
1873 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1874 },
1875 );
1876 for reg in app_assets {
1880 let key = reg.key;
1881 if key.is_empty()
1882 || key.starts_with('/')
1883 || key.split('/').any(|s| s.is_empty() || s == "..")
1884 {
1885 panic!("invalid admin asset key `{key}`");
1886 }
1887 if assets.insert(key, reg.asset).is_some() {
1888 panic!("duplicate admin asset `{key}`");
1889 }
1890 }
1891 let mut skins = skin::builtin();
1892 skins.extend(app_skins);
1893 skin::check(&skins);
1894
1895 for item in &settings {
1899 icons::require(
1900 &icon_set,
1901 &format!("settings item `{}`", item.code),
1902 item.icon.as_deref(),
1903 );
1904 }
1905 for node in nav
1906 .top
1907 .iter()
1908 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1909 {
1910 icons::require(
1911 &icon_set,
1912 &format!("menu entry `{}`", node.item.key),
1913 node.item.icon.as_deref(),
1914 );
1915 }
1916
1917 let mut column_types = list::builtin_column_registry();
1921 for reg in app_column_types {
1922 let name = reg.name().to_string();
1923 if !field::is_name(&name, true) {
1924 panic!("invalid column type name `{name}`");
1925 }
1926 if column_types.insert(name.clone(), reg.column_type).is_some() {
1927 panic!("duplicate column type `{name}`");
1928 }
1929 }
1930
1931 let settings = Arc::new(settings);
1932 let asset_urls_arc = Arc::new(asset_urls(&assets));
1934 let active_skin = Arc::new(skin::resolve(
1935 &config.skin,
1936 &skins,
1937 &admin_path,
1938 &asset_urls_arc,
1939 ));
1940 let sprite_url: Arc<str> = Arc::from(
1941 asset_urls_arc
1942 .get(ICON_SPRITE)
1943 .map(|p| format!("{admin_path}/assets/{p}"))
1944 .unwrap_or_default()
1945 .as_str(),
1946 );
1947 let icons_arc = Arc::new(icon_set);
1948 let mut widget_regs = widgets::builtin();
1949 for reg in app_widgets {
1950 if let Some(at) = widget_regs.iter().position(|w| w.code() == reg.code()) {
1951 widget_regs[at] = reg;
1952 } else {
1953 widget_regs.push(reg);
1954 }
1955 }
1956 let state = AdminState {
1957 auth,
1958 parameters: laterite_core::parameters::Parameters::new(db.clone()),
1959 upload_extensions: Arc::new(upload_extensions(&config.upload_extensions)),
1960 db,
1961 nav: Arc::new(nav),
1962 nav_items: Arc::new(nav_items),
1963 settings: settings.clone(),
1964 permissions: Arc::new(permissions),
1965 admin_path: Arc::from(admin_path.as_str()),
1966 secure_cookie: config.secure_cookie,
1967 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1968 icons: icons_arc.clone(),
1969 sprite_url: sprite_url.clone(),
1970 origin: Arc::from(config.origin.trim_end_matches('/')),
1971 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1972 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1973 catalogs,
1974 app_name,
1975 brand_cache: Arc::new(RwLock::new(None)),
1976 skin: active_skin,
1977 field_types: Arc::new(field_types),
1978 column_types: Arc::new(column_types),
1979 widgets: Arc::new(widget_regs),
1980 plugin_defined,
1981 storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
1982 crypt,
1983 order: config.order.clone(),
1984 cookie_path: if app_public.iter().any(|r| r.is_site()) {
1985 Arc::from("/")
1986 } else {
1987 Arc::from(admin_path.as_str())
1988 },
1989 assets: Arc::new(assets),
1990 asset_urls: asset_urls_arc.clone(),
1991 pickers,
1992 palette: Arc::new(palette_sources),
1993 overrides: Arc::new(field::NoOverrides),
1994 };
1995
1996 let mut protected = Router::new().route(&admin_path, get(dashboard));
1997 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
2001 for resource in &resources {
2002 let (mounted, prepared) = mount_resource(
2003 resource,
2004 &state.field_types,
2005 &state.column_types,
2006 &permission_codes,
2007 &persisters,
2008 &app_listeners,
2009 );
2010 protected = protected.merge(mounted);
2011 if let Some(prepared) = prepared {
2012 let key = resource
2013 .base_path
2014 .strip_prefix(admin_path.as_str())
2015 .unwrap_or(&resource.base_path);
2016 forms.insert(key.to_string(), prepared);
2017 }
2018 }
2019 let forms: form::Forms = Arc::new(forms);
2020 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
2024 .plugin_defined
2025 .items::<laterite_media::DiskReg>()
2026 .into_iter()
2027 .find(|d| d.name == import::DISK)
2028 .map(|d| d.driver.clone())
2029 .unwrap_or_else(|| {
2030 Arc::new(laterite_media::LocalDisk::new(format!(
2031 "{}/{}",
2032 config.storage_dir.trim_end_matches('/'),
2033 import::DISK
2034 )))
2035 });
2036 for resource in &resources {
2037 let Some(link) = &resource.list.import else {
2038 continue;
2039 };
2040 let key = resource
2041 .base_path
2042 .strip_prefix(admin_path.as_str())
2043 .unwrap_or(&resource.base_path)
2044 .to_string();
2045 let prepared = forms.get(&key).unwrap_or_else(|| {
2046 panic!(
2047 "admin resource `{}` declares `import` but has no form to import through",
2048 resource.base_path
2049 )
2050 });
2051 let feature = import::ImportFeature {
2052 disk: private_disk.clone(),
2053 };
2054 let base = link.href.clone();
2055 let ctx = routes::RouteCtx::new(
2056 state.db.clone(),
2057 state.auth.clone(),
2058 state.parameters.clone(),
2059 state.upload_extensions.clone(),
2060 state.field_types.clone(),
2061 state.overrides.clone(),
2062 state.crypt.clone(),
2063 &state.storage_dir,
2064 &base,
2065 &admin_path,
2066 &state.origin,
2067 state.plugin_defined.clone(),
2068 state.auth.events().clone(),
2069 forms.clone(),
2070 );
2071 let info = routes::ResourceInfo::new(
2072 key,
2073 resource.base_path.clone(),
2074 resource.nav_label.clone(),
2075 link.permission.clone(),
2076 form::FormHandle::new(state.db.clone(), prepared.clone()),
2077 );
2078 protected = protected.merge(guard_with_permission(
2079 Router::new()
2080 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
2081 &link.permission,
2082 ));
2083 }
2084 for reg in &app_screens {
2088 let base = format!("{admin_path}{}", reg.base_path);
2089 let ctx = routes::RouteCtx::new(
2090 state.db.clone(),
2091 state.auth.clone(),
2092 state.parameters.clone(),
2093 state.upload_extensions.clone(),
2094 state.field_types.clone(),
2095 state.overrides.clone(),
2096 state.crypt.clone(),
2097 &state.storage_dir,
2098 &base,
2099 &admin_path,
2100 &state.origin,
2101 state.plugin_defined.clone(),
2102 state.auth.events().clone(),
2103 forms.clone(),
2104 );
2105 protected = protected.merge(guard_with_permission(
2106 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
2107 ®.permission,
2108 ));
2109 }
2110
2111 protected = protected.merge(guard_with_permission(
2114 Router::new()
2115 .route(
2116 &format!("{admin_path}/roles/new"),
2117 get(roles::new_form).post(roles::create),
2118 )
2119 .route(
2120 &format!("{admin_path}/roles/{{id}}/edit"),
2121 get(roles::edit_form).post(roles::update),
2122 ),
2123 "backend.manage_roles",
2124 ));
2125 protected = protected.merge(guard_with_permission(
2128 Router::new()
2129 .route(
2130 &format!("{admin_path}/users/new"),
2131 get(users::new_form).post(users::create),
2132 )
2133 .route(
2134 &format!("{admin_path}/users/{{id}}/edit"),
2135 get(users::edit_form).post(users::update),
2136 )
2137 .route(
2138 &format!("{admin_path}/users/{{id}}/active"),
2139 post(users::set_active),
2140 )
2141 .route(
2142 &format!("{admin_path}/users/{{id}}/password"),
2143 post(users::reset_password),
2144 )
2145 .route(
2146 &format!("{admin_path}/users/{{id}}/unlock"),
2147 post(users::unlock),
2148 ),
2149 "backend.manage_users",
2150 ));
2151 protected = protected.merge(guard_with_permission(
2154 Router::new()
2155 .route(&format!("{admin_path}/plugins"), get(plugins::index))
2156 .route(
2157 &format!("{admin_path}/plugins/toggle"),
2158 post(plugins::toggle),
2159 ),
2160 plugins::MANAGE_PERMISSION,
2161 ));
2162 protected = protected
2163 .route(&format!("{admin_path}/settings"), get(settings_index))
2164 .route(
2165 &format!("{admin_path}/preferences"),
2166 get(preferences_form).post(preferences_update),
2167 )
2168 .route(
2169 &format!("{admin_path}/preferences/password"),
2170 post(password_update),
2171 )
2172 .route(
2173 &format!("{admin_path}/preferences/menu"),
2174 post(menu_order_update),
2175 )
2176 .route(
2177 &format!("{admin_path}/preferences/menu/moves"),
2178 post(menu_moves_update),
2179 )
2180 .route(
2181 &format!("{admin_path}/markdown/preview"),
2182 post(markdown_preview),
2183 )
2184 .route(&format!("{admin_path}/editor/menu"), get(editor_menu))
2185 .route(
2186 &format!("{admin_path}/dashboard/widget/{{code}}"),
2187 get(dashboard_widget),
2188 )
2189 .route(
2190 &format!("{admin_path}/dashboard/layout"),
2191 post(dashboard_layout_update),
2192 )
2193 .route(
2194 &format!("{admin_path}/dashboard/layout/reset"),
2195 post(dashboard_layout_reset),
2196 )
2197 .route(&format!("{admin_path}/palette"), get(palette::palette))
2198 .route(
2199 &format!("{admin_path}/preferences/sessions/revoke"),
2200 post(session_revoke),
2201 )
2202 .route(
2203 &format!("{admin_path}/preferences/sessions/revoke-others"),
2204 post(session_revoke_others),
2205 )
2206 .route(
2209 &format!("{admin_path}/pickers/{{source}}/search"),
2210 any(picker::search),
2211 )
2212 .route(
2213 &format!("{admin_path}/pickers/{{source}}/resolve"),
2214 any(picker::resolve),
2215 )
2216 .route(&format!("{admin_path}/logout"), post(logout));
2217
2218 for item in settings.iter().filter(|i| i.link.is_none()) {
2222 let path = format!("{admin_path}{}", item.route());
2223 let code = item.code.clone();
2224 let edit_code = code.clone();
2225 protected = protected.route(
2226 &path,
2227 get(
2228 move |State(state): State<AdminState>,
2229 Extension(shell): Extension<Shell>,
2230 Extension(user): Extension<AuthenticatedUser>| {
2231 let code = edit_code.clone();
2232 async move { settings_edit(state, shell, user, code).await }
2233 },
2234 )
2235 .post(
2236 move |State(state): State<AdminState>,
2237 Extension(shell): Extension<Shell>,
2238 Extension(user): Extension<AuthenticatedUser>,
2239 Extension(session): Extension<session::SessionHandle>,
2240 Form(data): Form<HashMap<String, String>>| {
2241 let code = code.clone();
2242 async move { settings_update(state, shell, user, session, code, data).await }
2243 },
2244 ),
2245 );
2246 }
2247
2248 let app = protected
2249 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
2250 .route(
2254 &format!("{admin_path}/login"),
2255 get(login_form).post(login_submit),
2256 )
2257 .route(
2258 &format!("{admin_path}/setup"),
2259 get(setup_form).post(setup_submit),
2260 )
2261 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
2262 .merge(mount_public(
2266 &app_public,
2267 &state.db,
2268 &state.auth,
2269 &state.parameters,
2270 &state.upload_extensions,
2271 &state.field_types,
2272 &state.overrides,
2273 &state.crypt,
2274 &state.storage_dir,
2275 &admin_path,
2276 &state.origin,
2277 &state.plugin_defined,
2278 state.auth.events(),
2279 &forms,
2280 ));
2281 let app = match app_public.iter().find(|r| r.is_site()) {
2284 Some(site) => {
2285 let ctx = routes::RouteCtx::new(
2286 state.db.clone(),
2287 state.auth.clone(),
2288 state.parameters.clone(),
2289 state.upload_extensions.clone(),
2290 state.field_types.clone(),
2291 state.overrides.clone(),
2292 state.crypt.clone(),
2293 &state.storage_dir,
2294 "/",
2295 &admin_path,
2296 &state.origin,
2297 state.plugin_defined.clone(),
2298 state.auth.events().clone(),
2299 forms.clone(),
2300 );
2301 app.fallback_service(site.route.mount(&ctx))
2302 }
2303 None => app.fallback(not_found_fallback),
2304 };
2305 let secure = state.secure_cookie;
2306 app
2307 .layer(middleware::from_fn_with_state(
2310 state.clone(),
2311 enforce_origin,
2312 ))
2313 .layer(middleware::from_fn_with_state(
2316 state.clone(),
2317 capture_client,
2318 ))
2319 .layer(
2324 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2325 header::CACHE_CONTROL,
2326 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
2327 ),
2328 )
2329 .layer(
2333 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2334 header::VARY,
2335 axum::http::HeaderValue::from_static("HX-Request"),
2336 ),
2337 )
2338 .layer(
2344 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2345 header::CONTENT_SECURITY_POLICY,
2346 axum::http::HeaderValue::from_static(CONTENT_SECURITY_POLICY),
2347 ),
2348 )
2349 .layer(
2350 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2351 header::X_CONTENT_TYPE_OPTIONS,
2352 axum::http::HeaderValue::from_static("nosniff"),
2353 ),
2354 )
2355 .layer(
2356 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2357 header::X_FRAME_OPTIONS,
2358 axum::http::HeaderValue::from_static("SAMEORIGIN"),
2359 ),
2360 )
2361 .layer(
2362 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2363 header::REFERRER_POLICY,
2364 axum::http::HeaderValue::from_static("same-origin"),
2365 ),
2366 )
2367 .layer(
2368 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2369 axum::http::HeaderName::from_static("permissions-policy"),
2370 axum::http::HeaderValue::from_static(
2371 "camera=(), microphone=(), geolocation=(), payment=()",
2372 ),
2373 ),
2374 )
2375 .layer(
2376 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2377 axum::http::HeaderName::from_static("cross-origin-opener-policy"),
2378 axum::http::HeaderValue::from_static("same-origin"),
2379 ),
2380 )
2381 .layer(
2384 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2385 header::STRICT_TRANSPORT_SECURITY,
2386 if secure {
2387 Some(axum::http::HeaderValue::from_static(
2388 "max-age=31536000; includeSubDomains",
2389 ))
2390 } else {
2391 None
2392 },
2393 ),
2394 )
2395 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
2396 handle_panic,
2397 ))
2398 .with_state(state)
2399}
2400
2401pub 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'";
2405
2406async fn not_found_fallback(
2409 State(state): State<AdminState>,
2410 method: axum::http::Method,
2411 uri: axum::http::Uri,
2412) -> AdminError {
2413 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
2414 AdminError::NotFound
2415}
2416
2417async fn not_found_plain() -> AdminError {
2419 AdminError::NotFound
2420}
2421
2422fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
2424 let cause = err
2425 .downcast_ref::<&str>()
2426 .map(|s| s.to_string())
2427 .or_else(|| err.downcast_ref::<String>().cloned())
2428 .unwrap_or_else(|| "unknown panic".to_string());
2429 tracing::error!(panic = cause, "admin handler panicked");
2430 error::masked_500()
2431}
2432
2433pub fn normalize_path(path: &str) -> String {
2438 let trimmed = path.trim().trim_matches('/');
2439 if trimmed.is_empty() {
2440 "/admin".to_string()
2441 } else {
2442 format!("/{trimmed}")
2443 }
2444}
2445
2446#[allow(clippy::too_many_arguments)]
2450fn mount_public(
2451 regs: &[routes::PublicRouteReg],
2452 db: &Db,
2453 auth: &AuthService,
2454 parameters: &laterite_core::parameters::Parameters,
2455 upload_extensions: &Arc<Vec<String>>,
2456 field_types: &Arc<field::FieldRegistry>,
2457 overrides: &Arc<dyn field::OverrideResolver>,
2458 crypt: &Arc<laterite_core::Crypt>,
2459 storage_dir: &str,
2460 admin_path: &str,
2461 origin: &str,
2462 plugin_defined: &Arc<laterite_core::Registry>,
2463 events: &laterite_core::Events,
2464 forms: &form::Forms,
2465) -> Router<AdminState> {
2466 let mut router = Router::new();
2467 for reg in regs.iter().filter(|r| !r.is_site()) {
2469 let ctx = routes::RouteCtx::new(
2470 db.clone(),
2471 auth.clone(),
2472 parameters.clone(),
2473 upload_extensions.clone(),
2474 field_types.clone(),
2475 overrides.clone(),
2476 crypt.clone(),
2477 storage_dir,
2478 ®.path,
2479 admin_path,
2480 origin,
2481 plugin_defined.clone(),
2482 events.clone(),
2483 forms.clone(),
2484 );
2485 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2490 }
2491 router
2492}
2493
2494pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2497 let old = resource.base_path.clone();
2498 resource.base_path = base.to_string();
2499 if let Some(edit_base) = &mut resource.list.edit_base {
2500 if edit_base == &old {
2501 *edit_base = base.to_string();
2502 }
2503 }
2504 if let Some(form) = &mut resource.form {
2505 if form.base_path == old {
2506 form.base_path = base.to_string();
2507 }
2508 }
2509}
2510
2511fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2512 resource.base_path = format!("{admin_path}{}", resource.base_path);
2513 if let Some(edit_base) = &mut resource.list.edit_base {
2514 *edit_base = format!("{admin_path}{edit_base}");
2515 }
2516 if let Some(form) = &mut resource.form {
2517 form.base_path = format!("{admin_path}{}", form.base_path);
2518 }
2519}
2520
2521#[derive(Debug, Clone)]
2525pub struct AdminAsset {
2526 pub mime: &'static str,
2527 pub bytes: std::borrow::Cow<'static, [u8]>,
2531}
2532
2533impl AdminAsset {
2534 pub fn new(mime: &'static str, bytes: &'static [u8]) -> Self {
2536 Self {
2537 mime,
2538 bytes: std::borrow::Cow::Borrowed(bytes),
2539 }
2540 }
2541
2542 pub fn css(bytes: &'static [u8]) -> Self {
2544 Self::new("text/css; charset=utf-8", bytes)
2545 }
2546
2547 pub fn js(bytes: &'static [u8]) -> Self {
2549 Self::new("text/javascript; charset=utf-8", bytes)
2550 }
2551}
2552
2553#[derive(Debug, Clone)]
2555pub struct AssetReg {
2556 pub key: &'static str,
2557 pub asset: AdminAsset,
2558}
2559
2560pub(crate) fn upload_extensions(configured: &[String]) -> Vec<String> {
2564 let listed: Vec<String> = configured
2565 .iter()
2566 .map(|e| e.trim().trim_start_matches('.').to_ascii_lowercase())
2567 .filter(|e| !e.is_empty())
2568 .collect();
2569 if listed.is_empty() {
2570 laterite_media::sanitize::DEFAULT_EXTENSIONS
2571 .iter()
2572 .map(|e| e.to_string())
2573 .collect()
2574 } else {
2575 listed
2576 }
2577}
2578
2579pub const SETUP_COMPLETED_AT: &str = "laterite.backend::setup.completed_at";
2584
2585pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2588
2589pub(crate) type AssetUrls = HashMap<&'static str, String>;
2593
2594pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2596 registry
2597 .iter()
2598 .map(|(&key, asset)| {
2599 (
2600 key,
2601 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2602 )
2603 })
2604 .collect()
2605}
2606
2607pub(crate) fn page_assets(
2614 keys: &[&str],
2615 base: &str,
2616 registry: &AssetRegistry,
2617 urls: &AssetUrls,
2618) -> Vec<PageAsset> {
2619 let mut seen = std::collections::HashSet::new();
2620 let mut out = Vec::new();
2621 for &key in keys {
2622 if !seen.insert(key) {
2623 continue;
2624 }
2625 match (registry.get(key), urls.get(key)) {
2626 (Some(asset), Some(path)) => out.push(PageAsset {
2627 url: format!("{base}/assets/{path}"),
2628 css: asset.mime.contains("css"),
2629 }),
2630 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2631 }
2632 }
2633 out
2634}
2635
2636pub const CODE_EDITOR_ASSETS: &[&str] = &[
2642 "vendor/codeeditor.js",
2643 "fields/codeeditor.js",
2644 "fields/codeeditor.css",
2645];
2646
2647pub const RICH_EDITOR_ASSETS: &[&str] = &[
2651 "vendor/richeditor.js",
2652 "vendor/richeditor.css",
2653 "fields/richeditor.js",
2654 "fields/richeditor.css",
2655];
2656
2657pub const MARKDOWN_ASSETS: &[&str] = &[
2661 "vendor/codeeditor.js",
2662 "fields/codeeditor.css",
2663 "fields/markdown.js",
2664 "fields/markdown.css",
2665];
2666
2667pub(crate) fn builtin_assets() -> AssetRegistry {
2678 let font = |bytes: &'static [u8]| AdminAsset {
2679 mime: "font/woff2",
2680 bytes: std::borrow::Cow::Borrowed(bytes),
2681 };
2682 HashMap::from([
2683 (
2684 "laterite.css",
2685 AdminAsset {
2686 mime: "text/css; charset=utf-8",
2687 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2688 },
2689 ),
2690 (
2691 "laterite.js",
2692 AdminAsset {
2693 mime: "text/javascript; charset=utf-8",
2694 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2695 },
2696 ),
2697 (
2698 skin::COMPACT_STYLESHEET,
2699 AdminAsset::css(include_bytes!("../assets/skins/compact.css")),
2700 ),
2701 (
2702 "theme-boot.js",
2703 AdminAsset {
2704 mime: "text/javascript; charset=utf-8",
2705 bytes: std::borrow::Cow::Borrowed(laterite_core::theme::BOOT_JS.as_bytes()),
2706 },
2707 ),
2708 (
2709 "vendor/htmx.min.js",
2710 AdminAsset {
2711 mime: "text/javascript; charset=utf-8",
2712 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2713 },
2714 ),
2715 (
2716 "vendor/richeditor.js",
2717 AdminAsset {
2718 mime: "text/javascript; charset=utf-8",
2719 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/richeditor.js")),
2720 },
2721 ),
2722 (
2723 "vendor/richeditor.css",
2724 AdminAsset {
2725 mime: "text/css; charset=utf-8",
2726 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2727 "../assets/vendor/richeditor.css"
2728 )),
2729 },
2730 ),
2731 (
2732 "vendor/codeeditor.js",
2733 AdminAsset {
2734 mime: "text/javascript; charset=utf-8",
2735 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/codeeditor.js")),
2736 },
2737 ),
2738 (
2739 "fields/richeditor.js",
2740 AdminAsset {
2741 mime: "text/javascript; charset=utf-8",
2742 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/richeditor.js")),
2743 },
2744 ),
2745 (
2746 "fields/richeditor.css",
2747 AdminAsset {
2748 mime: "text/css; charset=utf-8",
2749 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2750 "../assets/fields/richeditor.css"
2751 )),
2752 },
2753 ),
2754 (
2755 "fields/markdown.js",
2756 AdminAsset {
2757 mime: "text/javascript; charset=utf-8",
2758 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.js")),
2759 },
2760 ),
2761 (
2762 "fields/markdown.css",
2763 AdminAsset {
2764 mime: "text/css; charset=utf-8",
2765 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.css")),
2766 },
2767 ),
2768 (
2769 "fields/codeeditor.js",
2770 AdminAsset {
2771 mime: "text/javascript; charset=utf-8",
2772 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/codeeditor.js")),
2773 },
2774 ),
2775 (
2776 "fields/codeeditor.css",
2777 AdminAsset {
2778 mime: "text/css; charset=utf-8",
2779 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2780 "../assets/fields/codeeditor.css"
2781 )),
2782 },
2783 ),
2784 (
2785 "fields/ref-picker.js",
2786 AdminAsset {
2787 mime: "text/javascript; charset=utf-8",
2788 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2789 },
2790 ),
2791 (
2792 "fields/ref-picker.css",
2793 AdminAsset {
2794 mime: "text/css; charset=utf-8",
2795 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2796 "../assets/fields/ref-picker.css"
2797 )),
2798 },
2799 ),
2800 (
2801 "mark.svg",
2802 AdminAsset {
2803 mime: "image/svg+xml",
2804 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2805 },
2806 ),
2807 (
2808 "mark.png",
2809 AdminAsset {
2810 mime: "image/png",
2811 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2812 },
2813 ),
2814 (
2815 "fonts/space-grotesk-500.woff2",
2816 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2817 ),
2818 (
2819 "fonts/space-grotesk-600.woff2",
2820 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2821 ),
2822 (
2823 "fonts/space-grotesk-700.woff2",
2824 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2825 ),
2826 (
2827 "fonts/ibm-plex-sans-400.woff2",
2828 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2829 ),
2830 (
2831 "fonts/ibm-plex-sans-600.woff2",
2832 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2833 ),
2834 (
2835 "fonts/ibm-plex-mono-400.woff2",
2836 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2837 ),
2838 (
2839 "fonts/ibm-plex-mono-600.woff2",
2840 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2841 ),
2842 ])
2843}
2844
2845async fn serve_asset(
2847 State(state): State<AdminState>,
2848 headers: HeaderMap,
2849 Path(path): Path<String>,
2850) -> Response {
2851 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2855 let Some(asset) = state.assets.get(key.as_str()) else {
2856 return not_found();
2857 };
2858 let policy = if fingerprinted {
2859 http_cache::IMMUTABLE
2860 } else {
2861 http_cache::REVALIDATE
2862 };
2863 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2864}
2865
2866fn mount_resource(
2871 resource: &Resource,
2872 field_types: &field::FieldRegistry,
2873 column_types: &list::ColumnRegistry,
2874 permission_codes: &std::collections::HashSet<String>,
2875 persisters: &persist::PersisterRegistry,
2876 listeners: &[laterite_core::ModelListenerReg],
2877) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2878 list::check(&resource.list, column_types, permission_codes)
2882 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2883 if resource.form.is_some() && resource.permission.is_none() {
2888 panic!(
2889 "admin resource `{}` has a create/edit form but no permission; a write \
2890 resource must declare a permission",
2891 resource.base_path
2892 );
2893 }
2894 let base = resource.base_path.clone();
2895 let list_path = base.clone();
2896 let mut list_cfg = resource.list.clone();
2897 if resource.list.columns.iter().any(|c| c.editable) {
2900 let Some(form_cfg) = resource.form.clone() else {
2901 panic!(
2902 "admin resource `{}` has an editable column but no form to edit through",
2903 resource.base_path
2904 );
2905 };
2906 for column in resource.list.columns.iter().filter(|c| c.editable) {
2907 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2908 panic!(
2909 "admin resource `{}`: column `{}` is editable but the form has no such field",
2910 resource.base_path, column.field
2911 );
2912 }
2913 }
2914 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2915 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2916 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2917 }
2918 let cell_cfg = list_cfg.clone();
2919 let mut router = Router::new().route(
2920 &base,
2921 get(
2922 move |State(state): State<AdminState>,
2923 Extension(shell): Extension<Shell>,
2924 Query(params): Query<list::ListParams>,
2925 Query(raw): Query<std::collections::HashMap<String, String>>,
2926 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2927 Extension(user): Extension<AuthenticatedUser>,
2928 Extension(session): Extension<session::SessionHandle>,
2929 headers: axum::http::HeaderMap| {
2930 let cfg = list_cfg.clone();
2931 let path = list_path.clone();
2932 async move {
2933 list::handle_remembering(
2934 &state,
2935 &cfg,
2936 &path,
2937 params,
2938 &raw,
2939 &user,
2940 shell,
2941 &headers,
2942 query_string.as_deref(),
2943 Some(&session),
2944 )
2945 .await
2946 }
2947 },
2948 ),
2949 );
2950
2951 if cell_cfg.editor.is_some() {
2952 let (cfg, list_path) = (cell_cfg, base.clone());
2953 router = router.route(
2954 &format!("{base}/{{id}}/cell"),
2955 post(
2956 move |State(state): State<AdminState>,
2957 Extension(shell): Extension<Shell>,
2958 Extension(user): Extension<AuthenticatedUser>,
2959 Extension(session): Extension<session::SessionHandle>,
2960 Path(id): Path<String>,
2961 headers: axum::http::HeaderMap,
2962 Form(data): Form<HashMap<String, String>>| {
2963 let (cfg, path) = (cfg.clone(), list_path.clone());
2964 async move {
2965 list::cell_handler(
2966 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2967 )
2968 .await
2969 }
2970 },
2971 ),
2972 );
2973 }
2974
2975 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2978 if resource.list.exportable {
2979 router = router.route(
2980 &format!("{base}/export"),
2981 get(
2982 move |state: State<AdminState>,
2983 Query(raw): Query<std::collections::HashMap<String, String>>,
2984 Extension(user): Extension<AuthenticatedUser>,
2985 Extension(shell): Extension<Shell>,
2986 Extension(session): Extension<session::SessionHandle>| {
2987 let cfg = export_cfg.clone();
2988 let path = export_path.clone();
2989 async move {
2990 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2991 }
2992 },
2993 ),
2994 );
2995 }
2996
2997 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2998 router = router.route(
2999 &format!("{base}/columns"),
3000 post(
3001 move |State(state): State<AdminState>,
3002 Extension(user): Extension<AuthenticatedUser>,
3003 Extension(session): Extension<session::SessionHandle>,
3004 headers: axum::http::HeaderMap,
3005 Form(pairs): Form<Vec<(String, String)>>| {
3006 let cfg = columns_cfg.clone();
3007 let path = columns_path.clone();
3008 async move {
3009 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
3010 }
3011 },
3012 ),
3013 );
3014
3015 if resource.list.deletable {
3016 let persister: Arc<dyn persist::Persister> =
3020 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
3021 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
3022 panic!(
3023 "admin resource `{}`: unregistered persister `{key}`",
3024 resource.base_path
3025 )
3026 }),
3027 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
3028 };
3029 let ctx = Arc::new(bulk::BulkContext {
3030 base_path: resource.base_path.clone(),
3031 entity: resource.list.entity.clone(),
3032 persister,
3033 listeners: listeners
3034 .iter()
3035 .filter(|reg| reg.matches(&resource.list.entity))
3036 .map(|reg| reg.listener.clone())
3037 .collect(),
3038 });
3039 router =
3040 router.route(
3041 &format!("{base}/delete"),
3042 post(
3043 move |state: State<AdminState>,
3044 user: Extension<AuthenticatedUser>,
3045 session: Extension<session::SessionHandle>,
3046 headers: axum::http::HeaderMap,
3047 form: Form<Vec<(String, String)>>| {
3048 let ctx = ctx.clone();
3049 async move {
3050 bulk::delete_handler(state, user, session, ctx, headers, form).await
3051 }
3052 },
3053 ),
3054 );
3055 }
3056
3057 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
3060 let tree = resource.list.tree.clone().unwrap_or_default();
3061 let mut move_cfg = form::FormConfig::new(
3062 resource.list.entity.clone(),
3063 laterite_core::t!("Move to"),
3064 base.clone(),
3065 resource.list.id_field.clone(),
3066 vec![form::FormField::reference(
3067 &tree.parent_key,
3068 laterite_core::t!("New parent"),
3069 &source,
3070 )
3071 .help(laterite_core::t!("Leave empty for the top level."))],
3072 );
3073 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
3074 move_cfg = move_cfg.persist(persist);
3075 }
3076 move_cfg.done_path = Some(base.clone());
3077 let move_form = Arc::new(
3078 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
3079 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
3080 );
3081 let (cfg, list_path) = (resource.list.clone(), base.clone());
3082 let (form_get, form_post) = (move_form.clone(), move_form);
3083 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
3084 router = router.route(
3085 &format!("{base}/{{id}}/move"),
3086 get(
3087 move |State(state): State<AdminState>,
3088 Extension(shell): Extension<Shell>,
3089 Path(id): Path<String>| {
3090 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
3091 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
3092 },
3093 )
3094 .post(
3095 move |State(state): State<AdminState>,
3096 Extension(shell): Extension<Shell>,
3097 Extension(user): Extension<AuthenticatedUser>,
3098 Extension(session): Extension<session::SessionHandle>,
3099 Path(id): Path<String>,
3100 headers: axum::http::HeaderMap,
3101 Form(data): Form<HashMap<String, String>>| {
3102 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
3103 async move {
3104 list::move_record(
3105 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
3106 )
3107 .await
3108 }
3109 },
3110 ),
3111 );
3112 }
3113
3114 if resource.list.reorder.is_some() {
3115 let (cfg, list_path) = (resource.list.clone(), base.clone());
3116 router = router.route(
3117 &format!("{base}/reorder"),
3118 post(
3119 move |State(state): State<AdminState>,
3120 Extension(shell): Extension<Shell>,
3121 Extension(user): Extension<AuthenticatedUser>,
3122 Extension(session): Extension<session::SessionHandle>,
3123 headers: axum::http::HeaderMap,
3124 Form(pairs): Form<Vec<(String, String)>>| {
3125 let cfg = cfg.clone();
3126 let path = list_path.clone();
3127 async move {
3128 list::reorder_handler(
3129 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
3130 &headers, &pairs,
3131 )
3132 .await
3133 }
3134 },
3135 ),
3136 );
3137 }
3138
3139 if !resource.relations.is_empty() && resource.form.is_none() {
3142 panic!(
3143 "admin resource `{}` declares relations but has no form to show them on",
3144 resource.base_path
3145 );
3146 }
3147 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
3148 for rel in &resource.relations {
3149 let prepared = relation::PreparedRelation::prepare(
3150 resource,
3151 rel.clone(),
3152 field_types,
3153 column_types,
3154 permission_codes,
3155 persisters,
3156 listeners,
3157 )
3158 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3159 relations.push(Arc::new(prepared));
3160 }
3161 let relations = Arc::new(relations);
3162
3163 let mut prepared_form = None;
3164 if let Some(form_cfg) = resource.form.clone() {
3165 let mut prepared =
3168 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
3169 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3170 prepared.stay = !relations.is_empty();
3172 let prepared = Arc::new(prepared);
3173 prepared_form = Some(prepared.clone());
3174 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
3175 let relations_for_new = relations.clone();
3176 router = router.route(
3177 &format!("{base}/new"),
3178 get(
3179 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
3180 let pf = new_pf.clone();
3181 let relations = relations_for_new.clone();
3182 async move {
3183 let pending = relations
3184 .iter()
3185 .map(|r| shell.tt(&r.config.title))
3186 .collect();
3187 form::new_form(&state, &pf, shell, pending)
3188 }
3189 },
3190 )
3191 .post(
3192 move |State(state): State<AdminState>,
3193 Extension(shell): Extension<Shell>,
3194 Extension(user): Extension<AuthenticatedUser>,
3195 Extension(session): Extension<session::SessionHandle>,
3196 headers: axum::http::HeaderMap,
3197 Form(data): Form<HashMap<String, String>>| {
3198 let pf = create_pf.clone();
3199 async move {
3200 form::create(&state, &pf, data, shell, &user, &session, &headers).await
3201 }
3202 },
3203 ),
3204 );
3205
3206 let reveal_pf = prepared.clone();
3207 router = router.route(
3208 &format!("{base}/{{id}}/reveal/{{field}}"),
3209 get(
3210 move |State(state): State<AdminState>,
3211 Extension(user): Extension<AuthenticatedUser>,
3212 Path((id, field)): Path<(String, String)>| {
3213 let pf = reveal_pf.clone();
3214 async move { form::reveal(&state, &pf, &id, &field, &user).await }
3215 },
3216 ),
3217 );
3218
3219 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
3220 let relations_for_edit = relations.clone();
3221 router = router.route(
3222 &format!("{base}/{{id}}/edit"),
3223 get(
3224 move |State(state): State<AdminState>,
3225 Extension(shell): Extension<Shell>,
3226 Extension(user): Extension<AuthenticatedUser>,
3227 Path(id): Path<String>| {
3228 let pf = edit_pf.clone();
3229 let relations = relations_for_edit.clone();
3230 async move {
3231 let mut sections = Vec::new();
3232 for rel in relations.iter() {
3233 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
3234 sections.push(section);
3235 }
3236 }
3237 form::edit_form(&state, &pf, id, shell, sections).await
3238 }
3239 },
3240 )
3241 .post(
3242 move |State(state): State<AdminState>,
3243 Extension(shell): Extension<Shell>,
3244 Extension(user): Extension<AuthenticatedUser>,
3245 Extension(session): Extension<session::SessionHandle>,
3246 Path(id): Path<String>,
3247 headers: axum::http::HeaderMap,
3248 Form(data): Form<HashMap<String, String>>| {
3249 let pf = update_pf.clone();
3250 async move {
3251 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
3252 }
3253 },
3254 ),
3255 );
3256 }
3257
3258 for rel in relations.iter() {
3259 let name = rel.config.name.clone();
3260 let mut routes: Router<AdminState> = Router::new();
3261 let r = rel.clone();
3262 routes =
3263 routes.route(
3264 &format!("{base}/{{id}}/{name}"),
3265 get(
3266 move |state: State<AdminState>,
3267 shell: Extension<Shell>,
3268 user: Extension<AuthenticatedUser>,
3269 id: Path<String>,
3270 params: Query<list::ListParams>,
3271 raw: Query<HashMap<String, String>>,
3272 headers: axum::http::HeaderMap| {
3273 let r = r.clone();
3274 async move {
3275 relation::index(state, shell, user, id, params, raw, headers, r).await
3276 }
3277 },
3278 ),
3279 );
3280 let (r_new, r_create) = (rel.clone(), rel.clone());
3281 routes = routes.route(
3282 &format!("{base}/{{id}}/{name}/new"),
3283 get(
3284 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
3285 let r = r_new.clone();
3286 async move { relation::new_form(state, shell, id, r).await }
3287 },
3288 )
3289 .post(
3290 move |state: State<AdminState>,
3291 shell: Extension<Shell>,
3292 user: Extension<AuthenticatedUser>,
3293 session: Extension<session::SessionHandle>,
3294 id: Path<String>,
3295 headers: axum::http::HeaderMap,
3296 data: Form<HashMap<String, String>>| {
3297 let r = r_create.clone();
3298 async move {
3299 relation::create(state, shell, user, session, id, headers, data, r).await
3300 }
3301 },
3302 ),
3303 );
3304 let (r_edit, r_update) = (rel.clone(), rel.clone());
3305 routes = routes.route(
3306 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
3307 get(
3308 move |state: State<AdminState>,
3309 shell: Extension<Shell>,
3310 ids: Path<(String, String)>| {
3311 let r = r_edit.clone();
3312 async move { relation::edit_form(state, shell, ids, r).await }
3313 },
3314 )
3315 .post(
3316 move |state: State<AdminState>,
3317 shell: Extension<Shell>,
3318 user: Extension<AuthenticatedUser>,
3319 session: Extension<session::SessionHandle>,
3320 ids: Path<(String, String)>,
3321 headers: axum::http::HeaderMap,
3322 data: Form<HashMap<String, String>>| {
3323 let r = r_update.clone();
3324 async move {
3325 relation::update(state, shell, user, session, ids, headers, data, r).await
3326 }
3327 },
3328 ),
3329 );
3330 let r_delete = rel.clone();
3331 routes = routes.route(
3332 &format!("{base}/{{id}}/{name}/delete"),
3333 post(
3334 move |state: State<AdminState>,
3335 shell: Extension<Shell>,
3336 user: Extension<AuthenticatedUser>,
3337 session: Extension<session::SessionHandle>,
3338 id: Path<String>,
3339 headers: axum::http::HeaderMap,
3340 pairs: Form<Vec<(String, String)>>| {
3341 let r = r_delete.clone();
3342 async move {
3343 relation::delete(state, shell, user, session, id, headers, pairs, r).await
3344 }
3345 },
3346 ),
3347 );
3348 if rel.reorderable() {
3349 let r_reorder = rel.clone();
3350 routes = routes.route(
3351 &format!("{base}/{{id}}/{name}/reorder"),
3352 post(
3353 move |state: State<AdminState>,
3354 shell: Extension<Shell>,
3355 user: Extension<AuthenticatedUser>,
3356 session: Extension<session::SessionHandle>,
3357 id: Path<String>,
3358 headers: axum::http::HeaderMap,
3359 pairs: Form<Vec<(String, String)>>| {
3360 let r = r_reorder.clone();
3361 async move {
3362 relation::reorder(state, shell, user, session, id, headers, pairs, r)
3363 .await
3364 }
3365 },
3366 ),
3367 );
3368 }
3369 if let Some(permission) = &rel.config.permission {
3370 routes = guard_with_permission(routes, permission);
3371 }
3372 router = router.merge(routes);
3373 }
3374
3375 if let Some(permission) = &resource.permission {
3377 router = guard_with_permission(router, permission);
3378 }
3379 (router, prepared_form)
3380}
3381
3382fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
3387 let needed: Arc<str> = Arc::from(permission);
3388 router.route_layer(middleware::from_fn(
3389 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
3390 let needed = needed.clone();
3391 async move {
3392 if user.allows(&needed) {
3393 next.run(req).await
3394 } else {
3395 forbidden()
3396 }
3397 }
3398 },
3399 ))
3400}
3401
3402async fn require_auth(
3408 State(state): State<AdminState>,
3409 jar: CookieJar,
3410 request: Request,
3411 next: Next,
3412) -> Response {
3413 let login = format!("{}/login", state.admin_path);
3414 let client_ctx = request
3415 .extensions()
3416 .get::<RequestContext>()
3417 .cloned()
3418 .unwrap_or_default();
3419 let mut ended: Option<laterite_auth::SessionEnd> = None;
3424 let live = match jar.get(SESSION_COOKIE) {
3425 Some(cookie) => {
3426 let token = cookie.value().to_string();
3427 match state.auth.resolve_session(&token).await {
3428 Ok(resolved) => Some((token, resolved)),
3429 Err(_) => {
3430 ended = state.auth.session_end(&token).await.ok().flatten();
3431 None
3432 }
3433 }
3434 }
3435 None => None,
3436 };
3437 let htmx = request.headers().contains_key("hx-request");
3438 let (token, resolved, recalled) = match live {
3439 Some((token, resolved)) => (token, resolved, None),
3440 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
3443 return signed_out(&state, jar, &login, ended, htmx);
3444 }
3445 None => match recall(&state, &jar, &client_ctx).await {
3446 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
3447 None => {
3448 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3452 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
3453 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
3454 }
3455 }
3456 return signed_out(&state, jar, &login, ended, htmx);
3457 }
3458 },
3459 };
3460 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
3461
3462 let mut request = request;
3467 let safe = session::is_safe_method(request.method());
3468 let mut deferred: Option<session::CsrfPending> = None;
3470 if !safe {
3471 let (parts, body) = request.into_parts();
3472 let (body, submitted) = if session::is_multipart(&parts.headers) {
3479 let token = session::header_token(&parts.headers)
3480 .or_else(|| session::query_token(parts.uri.query()));
3481 if token.is_none() {
3486 deferred = Some(session::CsrfPending::default());
3487 }
3488 (body, token)
3489 } else {
3490 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
3491 .await
3492 .unwrap_or_default();
3493 let token = session::submitted_token(&parts.headers, &bytes);
3494 (Body::from(bytes), token)
3495 };
3496 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
3497 {
3498 tracing::warn!(
3499 user_id = resolved.identity.user.id,
3500 "admin CSRF check failed"
3501 );
3502 return error::csrf_rejected();
3503 }
3504 request = Request::from_parts(parts, body);
3505 }
3506
3507 let user = resolved.identity;
3508 let acting_user_id = user.user.id;
3510 let path = request.uri().path().to_string();
3511 let preferences = format!("{}/preferences", state.admin_path);
3514 if !path.starts_with(&preferences)
3515 && path != format!("{}/logout", state.admin_path)
3516 && state
3517 .auth
3518 .must_change_password(user.user.id)
3519 .await
3520 .unwrap_or(false)
3521 {
3522 let to = format!("{preferences}#password");
3523 let response = if htmx {
3524 (
3525 [("HX-Redirect", to.as_str())],
3526 axum::http::StatusCode::NO_CONTENT,
3527 )
3528 .into_response()
3529 } else {
3530 Redirect::to(&to).into_response()
3531 };
3532 return with_recall_cookies(response, recalled, jar, token, &state);
3533 }
3534 let flash = if safe {
3537 handle.take_flash()
3538 } else {
3539 Vec::new()
3540 };
3541 let accept_language = request
3545 .headers()
3546 .get("accept-language")
3547 .and_then(|v| v.to_str().ok());
3548 let serveable = state.catalogs.locales();
3549 let chain = resolve_locale_chain(
3550 user.user.locale.as_deref(),
3551 accept_language,
3552 &state.default_locale,
3553 &serveable,
3554 );
3555 let i18n = Translator::with_chain(chain, state.catalogs.clone());
3556 let translator = i18n.clone();
3559 let own = OwnMenu::load(&state, user.user.id).await;
3561 let (own_nav, own_settings) = own_tree(&state, &own);
3562 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
3563 &own_nav,
3564 &own_settings,
3565 &state.admin_path,
3566 &user.permissions,
3567 own.nav_order.as_deref(),
3568 &path,
3569 &i18n,
3570 icons::Icons::new(&state.icons, &state.sprite_url),
3571 &state.order,
3572 own.settings_order.as_deref(),
3573 );
3574 let brand = state.brand().await;
3575 let shell = Shell::new(
3576 &state.admin_path,
3577 brand,
3578 state.skin.clone(),
3579 &visible_nav,
3580 &user,
3581 state.timezone,
3582 sidebar,
3583 active_nav.as_deref(),
3584 handle.csrf_token(),
3585 flash,
3586 i18n,
3587 state.asset_urls.clone(),
3588 state.origin.to_string(),
3589 state.icons.clone(),
3590 state.sprite_url.clone(),
3591 );
3592 request.extensions_mut().insert(user);
3593 request.extensions_mut().insert(shell);
3594 request.extensions_mut().insert(handle.clone());
3595 if let Some(pending) = &deferred {
3597 request.extensions_mut().insert(pending.clone());
3598 }
3599 let mut response = next.run(request).await;
3600
3601 if let Some(pending) = &deferred {
3606 if !pending.was_satisfied() {
3607 tracing::error!(
3608 user_id = acting_user_id,
3609 "a multipart route returned without verifying its request token"
3610 );
3611 return error::csrf_rejected();
3612 }
3613 }
3614
3615 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
3619 response = error::localized_error(meta.kind, &translator);
3620 }
3621
3622 if let Some(blob) = handle.dirty_blob() {
3625 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
3626 tracing::error!(error = %e, "persisting admin session failed");
3627 }
3628 }
3629 with_recall_cookies(response, recalled, jar, token, &state)
3630}
3631
3632fn with_recall_cookies(
3636 response: Response,
3637 recalled: Option<laterite_auth::RememberCredential>,
3638 jar: CookieJar,
3639 token: String,
3640 state: &AdminState,
3641) -> Response {
3642 match recalled {
3643 Some(credential) => {
3644 let jar = jar
3645 .add(session_cookie(
3646 token,
3647 &state.cookie_path,
3648 state.secure_cookie,
3649 ))
3650 .add(remember_cookie(
3651 credential,
3652 &state.cookie_path,
3653 state.secure_cookie,
3654 ));
3655 (jar, response).into_response()
3656 }
3657 None => response,
3658 }
3659}
3660
3661async fn recall(
3665 state: &AdminState,
3666 jar: &CookieJar,
3667 ctx: &RequestContext,
3668) -> Option<(
3669 String,
3670 laterite_auth::ResolvedSession,
3671 laterite_auth::RememberCredential,
3672)> {
3673 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3674 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3675 let token = recalled.session.token.clone();
3676 let resolved = state.auth.resolve_session(&token).await.ok()?;
3677 Some((token, resolved, recalled.remember))
3678}
3679
3680const MAX_FORM_BYTES: usize = 1024 * 1024;
3683
3684async fn capture_client(
3692 State(state): State<AdminState>,
3693 mut request: Request,
3694 next: Next,
3695) -> Response {
3696 let peer = request
3697 .extensions()
3698 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3699 .map(|info| info.0);
3700 let headers = request.headers();
3701 let ctx = RequestContext {
3702 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3703 user_agent: clientip::user_agent(headers),
3704 };
3705 request.extensions_mut().insert(ctx);
3706 next.run(request).await
3707}
3708
3709async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3710 if !session::is_safe_method(request.method())
3711 && !session::origin_ok(request.headers(), &state.origin)
3712 {
3713 tracing::warn!(
3714 path = request.uri().path(),
3715 "admin CSRF origin check failed"
3716 );
3717 return error::csrf_rejected();
3718 }
3719 next.run(request).await
3720}
3721
3722#[derive(Deserialize)]
3723struct LoginQuery {
3724 ended: Option<String>,
3725}
3726
3727async fn login_form(
3728 State(state): State<AdminState>,
3729 headers: HeaderMap,
3730 Query(query): Query<LoginQuery>,
3731) -> Response {
3732 match state.auth.has_any_operator().await {
3734 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3735 Ok(true) => {
3736 let i18n = pre_auth_translator(&state, &headers);
3737 let notice = query
3738 .ended
3739 .as_deref()
3740 .and_then(end_notice)
3741 .map(|text| i18n.t(&text));
3742 render(LoginTemplate {
3743 base: state.admin_path.to_string(),
3744 asset_urls: state.asset_urls.clone(),
3745 brand: state.brand().await.name.clone(),
3746 look: state.brand().await,
3747 skin: state.skin.clone(),
3748 error: None,
3749 notice,
3750 i18n,
3751 })
3752 }
3753 Err(_) => render_error(),
3754 }
3755}
3756
3757#[derive(Deserialize)]
3758struct LoginForm {
3759 username: String,
3760 password: String,
3761 #[serde(default)]
3763 remember: Option<String>,
3764}
3765
3766async fn login_submit(
3767 State(state): State<AdminState>,
3768 jar: CookieJar,
3769 headers: HeaderMap,
3770 Extension(ctx): Extension<RequestContext>,
3771 Form(form): Form<LoginForm>,
3772) -> Response {
3773 match state
3774 .auth
3775 .authenticate(&form.username, &form.password, &ctx)
3776 .await
3777 {
3778 Ok(session) => {
3779 let wants_remember = form.remember.is_some();
3780 let user_id = session.user_id;
3781 let mut jar = jar.add(session_cookie(
3782 session.token,
3783 &state.cookie_path,
3784 state.secure_cookie,
3785 ));
3786 if wants_remember {
3787 match state.auth.issue_remember(user_id).await {
3788 Ok(credential) => {
3789 jar = jar.add(remember_cookie(
3790 credential,
3791 &state.cookie_path,
3792 state.secure_cookie,
3793 ));
3794 }
3795 Err(e) => {
3797 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3798 }
3799 }
3800 }
3801 (jar, Redirect::to(&state.admin_path)).into_response()
3802 }
3803 Err(_) => {
3804 let i18n = pre_auth_translator(&state, &headers);
3805 render(LoginTemplate {
3806 base: state.admin_path.to_string(),
3807 asset_urls: state.asset_urls.clone(),
3808 brand: state.brand().await.name.clone(),
3809 look: state.brand().await,
3810 skin: state.skin.clone(),
3811 error: Some(i18n.t(&t!("Invalid username or password."))),
3812 notice: None,
3813 i18n,
3814 })
3815 }
3816 }
3817}
3818
3819fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3829 Cookie::build((SESSION_COOKIE, token))
3830 .path(admin_path.to_string())
3831 .http_only(true)
3832 .secure(secure)
3833 .same_site(SameSite::Lax)
3834 .build()
3835}
3836
3837fn remember_cookie(
3840 credential: laterite_auth::RememberCredential,
3841 admin_path: &str,
3842 secure: bool,
3843) -> Cookie<'static> {
3844 let seconds = (credential.expires_at - chrono::Utc::now())
3845 .num_seconds()
3846 .max(0);
3847 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3848 .path(admin_path.to_string())
3849 .http_only(true)
3850 .secure(secure)
3851 .same_site(SameSite::Lax)
3852 .max_age(cookie::time::Duration::seconds(seconds))
3853 .build()
3854}
3855
3856fn signed_out(
3863 state: &AdminState,
3864 jar: CookieJar,
3865 login: &str,
3866 ended: Option<laterite_auth::SessionEnd>,
3867 htmx: bool,
3868) -> Response {
3869 let to = match ended.map(laterite_auth::SessionEnd::code) {
3870 Some(code) => format!("{login}?ended={code}"),
3871 None => login.to_string(),
3872 };
3873 let jar = jar
3874 .remove(
3875 Cookie::build((SESSION_COOKIE, ""))
3876 .path(state.cookie_path.to_string())
3877 .build(),
3878 )
3879 .remove(remember_removal(&state.cookie_path));
3880 if htmx {
3881 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3882 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3883 response.headers_mut().insert("hx-redirect", value);
3884 }
3885 return response;
3886 }
3887 (jar, Redirect::to(&to)).into_response()
3888}
3889
3890fn end_notice(code: &str) -> Option<Text> {
3893 Some(match code {
3894 "password_changed" => {
3895 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3896 }
3897 "deactivated" => t!("This account was deactivated."),
3898 "signed_out_elsewhere" => t!("You were signed out from another device."),
3899 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3900 _ => return None,
3901 })
3902}
3903
3904fn remember_removal(admin_path: &str) -> Cookie<'static> {
3906 Cookie::build((REMEMBER_COOKIE, ""))
3907 .path(admin_path.to_string())
3908 .build()
3909}
3910
3911#[derive(Deserialize)]
3912struct SetupForm {
3913 username: String,
3914 first_name: String,
3915 last_name: String,
3916 email: String,
3917 password: String,
3918 timezone: String,
3919}
3920
3921async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3924 match state.auth.has_any_operator().await {
3925 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3926 Ok(false) => render(setup_view(
3927 &state.admin_path,
3928 state.brand().await,
3929 state.skin.clone(),
3930 state.timezone,
3931 None,
3932 pre_auth_translator(&state, &headers),
3933 state.asset_urls.clone(),
3934 state.auth.password_policy().min_length,
3935 )),
3936 Err(_) => render_error(),
3937 }
3938}
3939
3940async fn setup_submit(
3941 State(state): State<AdminState>,
3942 jar: CookieJar,
3943 headers: HeaderMap,
3944 client: Option<Extension<RequestContext>>,
3945 Form(form): Form<SetupForm>,
3946) -> Response {
3947 match state.auth.has_any_operator().await {
3949 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3950 Ok(false) => {}
3951 Err(_) => return render_error(),
3952 }
3953
3954 let username = form.username.trim();
3955 let email = form.email.trim();
3956 let first_name = form.first_name.trim();
3957 let last_name = form.last_name.trim();
3958 let tz = form.timezone.trim();
3959 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3960 {
3961 return render(setup_view(
3962 &state.admin_path,
3963 state.brand().await,
3964 state.skin.clone(),
3965 state.timezone,
3966 Some(t!(
3967 "Username, first name, email, and password are all required."
3968 )),
3969 pre_auth_translator(&state, &headers),
3970 state.asset_urls.clone(),
3971 state.auth.password_policy().min_length,
3972 ));
3973 }
3974 if tz.parse::<Tz>().is_err() {
3976 return render(setup_view(
3977 &state.admin_path,
3978 state.brand().await,
3979 state.skin.clone(),
3980 state.timezone,
3981 Some(t!("That is not a recognised timezone.")),
3982 pre_auth_translator(&state, &headers),
3983 state.asset_urls.clone(),
3984 state.auth.password_policy().min_length,
3985 ));
3986 }
3987
3988 let min_length = state.auth.password_policy().min_length;
3989 if form.password.chars().count() < min_length {
3990 return render(setup_view(
3991 &state.admin_path,
3992 state.brand().await,
3993 state.skin.clone(),
3994 state.timezone,
3995 Some(t!(
3996 "A password needs at least {n} characters.",
3997 n = min_length as i64
3998 )),
3999 pre_auth_translator(&state, &headers),
4000 state.asset_urls.clone(),
4001 state.auth.password_policy().min_length,
4002 ));
4003 }
4004 let new = NewOperator {
4005 username,
4006 email,
4007 first_name,
4008 last_name: (!last_name.is_empty()).then_some(last_name),
4009 password: &form.password,
4010 timezone: Some(tz),
4011 };
4012 let id = match state.auth.create_superuser(new).await {
4013 Ok(id) => id,
4014 Err(_) => {
4015 return render(setup_view(
4016 &state.admin_path,
4017 state.brand().await,
4018 state.skin.clone(),
4019 state.timezone,
4020 Some(t!(
4021 "Could not create the account. The username or email may already be taken."
4022 )),
4023 pre_auth_translator(&state, &headers),
4024 state.asset_urls.clone(),
4025 state.auth.password_policy().min_length,
4026 ));
4027 }
4028 };
4029 let actor = laterite_core::Actor::system("first-run setup");
4032 let target = id.to_string();
4033 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
4034 "backend_user",
4035 &target,
4036 Some(username),
4037 );
4038 if let Err(e) = state.auth.record_audit(entry).await {
4039 tracing::error!(error = %e, "failed to write audit log entry");
4040 }
4041 let completed = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4042 if let Err(e) = state.parameters.set(SETUP_COMPLETED_AT, &completed).await {
4043 tracing::warn!(error = %e, "failed to record the setup completion");
4044 }
4045
4046 match state
4048 .auth
4049 .authenticate(
4050 username,
4051 &form.password,
4052 &client.map(|c| c.0).unwrap_or_default(),
4053 )
4054 .await
4055 {
4056 Ok(session) => {
4057 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
4058 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
4059 }
4060 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4061 }
4062}
4063
4064#[allow(clippy::too_many_arguments)]
4067fn setup_view(
4068 admin_path: &str,
4069 brand: Arc<settings::brand::Brand>,
4070 skin: Arc<skin::Active>,
4071 default_tz: Tz,
4072 error: Option<Text>,
4073 i18n: Translator,
4074 asset_urls: Arc<AssetUrls>,
4075 min_password: usize,
4076) -> SetupTemplate {
4077 let default_name = default_tz.name();
4078 let zones = TZ_VARIANTS
4079 .iter()
4080 .filter(|tz| zone_offered(tz.name()))
4081 .map(|tz| TzOption {
4082 name: tz.name().to_string(),
4083 selected: tz.name() == default_name,
4084 })
4085 .collect();
4086 SetupTemplate {
4087 base: admin_path.to_string(),
4088 asset_urls,
4089 brand: brand.name.clone(),
4090 look: brand,
4091 skin,
4092 zones,
4093 error: error.map(|e| i18n.t(&e)),
4094 min_password: min_password as i64,
4095 i18n,
4096 }
4097}
4098
4099async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
4100 if let Some(cookie) = jar.get(SESSION_COOKIE) {
4101 let _ = state.auth.logout(cookie.value()).await;
4102 }
4103 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
4106 let _ = state.auth.revoke_remember(cookie.value()).await;
4107 }
4108 let removal = Cookie::build((SESSION_COOKIE, ""))
4109 .path(state.cookie_path.to_string())
4110 .build();
4111 (
4112 jar.remove(removal)
4113 .remove(remember_removal(&state.cookie_path)),
4114 Redirect::to(&format!("{}/login", state.admin_path)),
4115 )
4116 .into_response()
4117}
4118
4119async fn dashboard(
4120 State(state): State<AdminState>,
4121 Extension(shell): Extension<Shell>,
4122 Extension(user): Extension<AuthenticatedUser>,
4123) -> Response {
4124 let layout = laterite_auth::store::user_preference(&state.db, user.user.id, widget::PREFERENCE)
4125 .await
4126 .ok()
4127 .flatten()
4128 .and_then(|json| widget::Layout::parse(&json));
4129 let deployment = state.order.get(widget::HIERARCHY).map(Vec::as_slice);
4130 let allows = |p: &str| user.permissions.allows(p);
4131 let available = widget::available(&state.widgets, deployment, &allows);
4132 let shown = widget::resolve(layout.as_ref(), &available);
4133 let failed = shell.tt(&t!("This widget could not be shown."));
4134 let mut widgets = Vec::with_capacity(shown.len());
4135 for (reg, size) in &shown {
4136 let cx = widget::WidgetCx {
4137 state: &state,
4138 shell: &shell,
4139 user: &user,
4140 size: *size,
4141 };
4142 let body = reg.widget.render(&cx).await;
4143 widgets.push(widget::frame(
4144 reg,
4145 *size,
4146 &shell.base,
4147 &shell.tt(®.widget.label()),
4148 body,
4149 &failed,
4150 ));
4151 }
4152 let available = available
4153 .iter()
4154 .map(|reg| {
4155 let placed = shown.iter().find(|(r, _)| r.code() == reg.code());
4156 AvailableWidgetView {
4157 code: reg.code().to_string(),
4158 label: shell.tt(®.widget.label()),
4159 description: reg
4160 .widget
4161 .description()
4162 .map(|d| shell.tt(&d))
4163 .unwrap_or_default(),
4164 shown: placed.is_some(),
4165 size: placed
4166 .map(|(_, s)| *s)
4167 .unwrap_or_else(|| reg.widget.size())
4168 .key()
4169 .to_string(),
4170 }
4171 })
4172 .collect();
4173 render(DashboardTemplate {
4174 shell,
4175 widgets,
4176 available,
4177 })
4178}
4179
4180async fn dashboard_widget(
4182 State(state): State<AdminState>,
4183 Extension(shell): Extension<Shell>,
4184 Extension(user): Extension<AuthenticatedUser>,
4185 Path(code): Path<String>,
4186 Query(query): Query<HashMap<String, String>>,
4187) -> Response {
4188 let Some(reg) = state.widgets.iter().find(|w| w.code() == code) else {
4189 return AdminError::NotFound.into_response();
4190 };
4191 if let Some(p) = reg.widget.permission() {
4192 if !user.permissions.allows(p) {
4193 return AdminError::Forbidden.into_response();
4194 }
4195 }
4196 let size = query
4197 .get("size")
4198 .and_then(|s| widget::Size::parse(s))
4199 .unwrap_or_else(|| reg.widget.size());
4200 let cx = widget::WidgetCx {
4201 state: &state,
4202 shell: &shell,
4203 user: &user,
4204 size,
4205 };
4206 match reg.widget.render(&cx).await {
4207 Ok(markup) => Html(markup.into_string()).into_response(),
4208 Err(e) => {
4209 tracing::warn!(widget = reg.code(), error = %e, "widget failed to render");
4210 Html(format!(
4211 r#"<p class="lat-widget__failed">{}</p>"#,
4212 form::escape_attr(&shell.tt(&t!("This widget could not be shown.")))
4213 ))
4214 .into_response()
4215 }
4216 }
4217}
4218
4219async fn dashboard_layout_update(
4222 State(state): State<AdminState>,
4223 Extension(user): Extension<AuthenticatedUser>,
4224 Extension(session): Extension<session::SessionHandle>,
4225 body: String,
4226) -> Response {
4227 let mut layout = widget::Layout::default();
4228 let mut sizes: HashMap<String, widget::Size> = HashMap::new();
4229 let mut order: Vec<String> = Vec::new();
4230 for (key, value) in form_urlencoded::parse(body.as_bytes()) {
4231 if key == "widget" {
4232 if state.widgets.iter().any(|w| w.code() == value) {
4233 order.push(value.to_string());
4234 }
4235 } else if let Some(code) = key.strip_prefix("size:") {
4236 if let Some(size) = widget::Size::parse(&value) {
4237 sizes.insert(code.to_string(), size);
4238 }
4239 }
4240 }
4241 for code in order {
4242 let size = sizes.get(&code).copied().unwrap_or_default();
4243 layout.widgets.push(widget::Placed { code, size });
4244 }
4245 let json = serde_json::to_string(&layout).unwrap_or_default();
4246 if let Err(e) = laterite_auth::store::set_user_preference(
4247 &state.db,
4248 user.user.id,
4249 widget::PREFERENCE,
4250 &json,
4251 )
4252 .await
4253 {
4254 tracing::error!(error = %e, "failed to save the dashboard layout");
4255 session.push_flash(FlashLevel::Error, t!("The dashboard could not be saved."));
4256 } else {
4257 session.push_flash(FlashLevel::Success, t!("Dashboard saved."));
4258 }
4259 Redirect::to(&state.admin_path).into_response()
4260}
4261
4262async fn dashboard_layout_reset(
4264 State(state): State<AdminState>,
4265 Extension(user): Extension<AuthenticatedUser>,
4266 Extension(session): Extension<session::SessionHandle>,
4267) -> Response {
4268 if let Err(e) =
4269 laterite_auth::store::clear_user_preference(&state.db, user.user.id, widget::PREFERENCE)
4270 .await
4271 {
4272 tracing::error!(error = %e, "failed to reset the dashboard layout");
4273 }
4274 session.push_flash(FlashLevel::Success, t!("Dashboard reset."));
4275 Redirect::to(&state.admin_path).into_response()
4276}
4277
4278struct AvailableWidgetView {
4280 code: String,
4281 label: String,
4282 description: String,
4283 shown: bool,
4284 size: String,
4285}
4286
4287fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
4290 match &item.permission {
4291 None => true,
4292 Some(p) => perms.allows(p),
4293 }
4294}
4295
4296fn visible_settings(
4300 items: &[settings::SettingsItem],
4301 perms: &PermissionSet,
4302) -> Vec<settings::SettingsItem> {
4303 items
4304 .iter()
4305 .filter(|item| operator_can_see(item, perms))
4306 .cloned()
4307 .collect()
4308}
4309
4310async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
4311 settings::index(shell)
4312}
4313
4314async fn settings_edit(
4315 state: AdminState,
4316 shell: Shell,
4317 user: AuthenticatedUser,
4318 code: String,
4319) -> Response {
4320 let items = visible_settings(&state.settings, &user.permissions);
4323 match items
4324 .iter()
4325 .find(|item| item.code == code && item.link.is_none())
4326 {
4327 Some(item) => settings::edit_form(&state, item, shell, &user).await,
4328 None => not_found(),
4329 }
4330}
4331
4332async fn settings_update(
4333 state: AdminState,
4334 shell: Shell,
4335 user: AuthenticatedUser,
4336 session: session::SessionHandle,
4337 code: String,
4338 data: HashMap<String, String>,
4339) -> Response {
4340 let items = visible_settings(&state.settings, &user.permissions);
4341 match items
4342 .iter()
4343 .find(|item| item.code == code && item.link.is_none())
4344 {
4345 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
4346 None => not_found(),
4347 }
4348}
4349
4350async fn preferences_form(
4352 State(state): State<AdminState>,
4353 Extension(shell): Extension<Shell>,
4354 Extension(user): Extension<AuthenticatedUser>,
4355 jar: CookieJar,
4356) -> Response {
4357 let account = account_security(&state, &user, &shell, &jar).await;
4358 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
4359 .into_iter()
4360 .filter(|i| i.per_operator)
4361 .map(|i| PreferenceLink {
4362 label: shell.tt(&i.label),
4363 description: shell.tt(&i.description),
4364 href: i.path(&state.admin_path),
4365 })
4366 .collect();
4367 let own = OwnMenu::load(&state, user.user.id).await;
4368 let chosen = |key: &str| -> String {
4369 own.moves
4370 .get(key)
4371 .map(|m| {
4372 if m.hierarchy == nav::TOP_HIERARCHY {
4373 "nav".to_string()
4374 } else {
4375 "settings".to_string()
4376 }
4377 })
4378 .unwrap_or_default()
4379 };
4380 let mut menu_entries: Vec<MenuEntryView> = state
4381 .nav_items
4382 .iter()
4383 .filter(|i| i.path.is_some() && !matches!(i.key.as_str(), "dashboard" | "settings"))
4384 .filter(|i| {
4385 i.permission
4386 .as_deref()
4387 .is_none_or(|p| user.permissions.allows(p))
4388 })
4389 .map(|i| MenuEntryView {
4390 key: i.key.clone(),
4391 label: shell.tt(&i.label),
4392 home: "nav",
4393 chosen: chosen(&i.key),
4394 })
4395 .collect();
4396 menu_entries.extend(
4397 visible_settings(&state.settings, &user.permissions)
4398 .into_iter()
4399 .filter(|i| !i.per_operator)
4400 .map(|i| MenuEntryView {
4401 chosen: chosen(&i.code),
4402 key: i.code,
4403 label: shell.tt(&i.label),
4404 home: "settings",
4405 }),
4406 );
4407 render(preferences_view(
4408 &shell,
4409 &user,
4410 state.timezone,
4411 &state.default_locale,
4412 &offered_locales(&state.catalogs),
4413 account,
4414 state.auth.password_policy().min_length,
4415 items,
4416 menu_entries,
4417 ))
4418}
4419
4420struct PreferenceLink {
4422 label: String,
4423 description: String,
4424 href: String,
4425}
4426
4427#[derive(Deserialize)]
4428struct RevokeSessionForm {
4429 id: String,
4430}
4431
4432async fn session_revoke(
4434 State(state): State<AdminState>,
4435 Extension(user): Extension<AuthenticatedUser>,
4436 Extension(session): Extension<session::SessionHandle>,
4437 Form(form): Form<RevokeSessionForm>,
4438) -> Response {
4439 match state.auth.revoke_session(user.user.id, &form.id).await {
4440 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
4441 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
4442 Err(e) => {
4443 tracing::error!(error = %e, "revoking a session failed");
4444 session.push_flash(
4445 FlashLevel::Error,
4446 t!("That session could not be signed out."),
4447 );
4448 }
4449 }
4450 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4451}
4452
4453#[derive(Deserialize)]
4455struct MenuOrderForm {
4456 #[serde(default)]
4457 order: String,
4458 #[serde(default)]
4460 hierarchy: String,
4461}
4462
4463async fn menu_moves_update(
4467 State(state): State<AdminState>,
4468 Extension(user): Extension<AuthenticatedUser>,
4469 Extension(session): Extension<session::SessionHandle>,
4470 Form(form): Form<HashMap<String, String>>,
4471) -> Response {
4472 let mut moves = serde_json::Map::new();
4473 for (field, place) in &form {
4474 let Some(key) = field.strip_prefix("place:") else {
4475 continue;
4476 };
4477 if key.is_empty() || key.len() > 200 || !matches!(place.as_str(), "nav" | "settings") {
4478 continue;
4479 }
4480 moves.insert(key.to_string(), serde_json::Value::String(place.clone()));
4481 }
4482 let result = if moves.is_empty() {
4483 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::MOVES_PREFERENCE)
4484 .await
4485 } else {
4486 laterite_auth::store::set_user_preference(
4487 &state.db,
4488 user.user.id,
4489 nav::MOVES_PREFERENCE,
4490 &serde_json::Value::Object(moves).to_string(),
4491 )
4492 .await
4493 };
4494 if let Err(e) = result {
4495 tracing::error!(error = %e, "storing the menu moves failed");
4496 return render_error();
4497 }
4498 session.push_flash(session::FlashLevel::Success, t!("Menu saved."));
4499 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4500}
4501
4502async fn markdown_preview(
4505 Extension(_user): Extension<AuthenticatedUser>,
4506 Form(form): Form<HashMap<String, String>>,
4507) -> Response {
4508 let text = form.get("text").map(String::as_str).unwrap_or_default();
4509 if text.len() > 1 << 20 {
4510 return (axum::http::StatusCode::PAYLOAD_TOO_LARGE, "text over 1 MiB").into_response();
4511 }
4512 Html(laterite_core::markdown::render(text)).into_response()
4513}
4514
4515async fn editor_menu(
4518 State(state): State<AdminState>,
4519 Extension(shell): Extension<Shell>,
4520 Extension(_user): Extension<AuthenticatedUser>,
4521 Query(query): Query<HashMap<String, String>>,
4522) -> Response {
4523 let Some(surface) = query.get("surface").and_then(|k| editor::Surface::parse(k)) else {
4524 return (axum::http::StatusCode::BAD_REQUEST, "unknown surface").into_response();
4525 };
4526 let registry = &state.plugin_defined;
4527 let mut menu = editor::Menu::default();
4528 for cmd in editor::commands(
4529 registry.items::<editor::EditorCommand>().into_iter(),
4530 surface,
4531 ) {
4532 let (html, text) = match &cmd.insert {
4533 editor::Insert::Html(h) => (Some(h.clone()), None),
4534 editor::Insert::Text(t) => (None, Some(t.clone())),
4535 };
4536 menu.commands.push(editor::MenuCommand {
4537 key: cmd.key.clone(),
4538 label: shell.tt(&cmd.label),
4539 html,
4540 text,
4541 });
4542 }
4543 for source in registry.items::<editor::SnippetSourceReg>() {
4544 menu.snippets.extend(source.0.list(&state.db).await);
4545 }
4546 menu.snippets.sort_by(|a, b| a.name.cmp(&b.name));
4547 axum::Json(menu).into_response()
4548}
4549
4550async fn menu_order_update(
4555 State(state): State<AdminState>,
4556 Extension(user): Extension<AuthenticatedUser>,
4557 Extension(session): Extension<session::SessionHandle>,
4558 headers: axum::http::HeaderMap,
4559 Form(form): Form<MenuOrderForm>,
4560) -> Response {
4561 let order: Vec<&str> = form
4562 .order
4563 .split(',')
4564 .map(str::trim)
4565 .filter(|k| !k.is_empty() && k.len() <= 200)
4566 .take(100)
4567 .collect();
4568 let hierarchy = if form.hierarchy.is_empty() {
4569 nav::TOP_HIERARCHY
4570 } else {
4571 form.hierarchy.as_str()
4572 };
4573 if !nav::OPERATOR_HIERARCHIES.contains(&hierarchy) {
4574 return AdminError::NotFound.into_response();
4575 }
4576 let key = nav::order_preference(hierarchy);
4577 let result = if order.is_empty() {
4578 let mut cleared = Ok(());
4580 for key in nav::OPERATOR_HIERARCHIES
4581 .iter()
4582 .map(|h| nav::order_preference(h))
4583 .chain(std::iter::once(nav::MOVES_PREFERENCE.to_string()))
4584 {
4585 if let Err(e) =
4586 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
4587 {
4588 cleared = Err(e);
4589 }
4590 }
4591 cleared
4592 } else {
4593 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &order.join(","))
4594 .await
4595 };
4596 if let Err(e) = result {
4597 tracing::error!(error = %e, "storing the menu order failed");
4598 return render_error();
4599 }
4600 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
4601 return axum::http::StatusCode::NO_CONTENT.into_response();
4602 }
4603 session.push_flash(
4604 FlashLevel::Success,
4605 if order.is_empty() {
4606 t!("The menu is back in its default order.")
4607 } else {
4608 t!("Saved.")
4609 },
4610 );
4611 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4612}
4613
4614async fn session_revoke_others(
4615 State(state): State<AdminState>,
4616 Extension(user): Extension<AuthenticatedUser>,
4617 Extension(session): Extension<session::SessionHandle>,
4618 jar: CookieJar,
4619) -> Response {
4620 let token = jar
4621 .get(SESSION_COOKIE)
4622 .map(|c| c.value().to_string())
4623 .unwrap_or_default();
4624 match state.auth.sign_out_everywhere(user.user.id, &token).await {
4625 Ok(_) => session.push_flash(
4626 FlashLevel::Success,
4627 t!("Every other session was signed out."),
4628 ),
4629 Err(e) => {
4630 tracing::error!(error = %e, "signing out other sessions failed");
4631 session.push_flash(
4632 FlashLevel::Error,
4633 t!("Those sessions could not be signed out."),
4634 );
4635 }
4636 }
4637 (
4640 jar.remove(remember_removal(&state.cookie_path)),
4641 Redirect::to(&format!("{}/preferences", state.admin_path)),
4642 )
4643 .into_response()
4644}
4645
4646#[derive(Deserialize)]
4647struct PasswordForm {
4648 current_password: String,
4649 new_password: String,
4650 confirm_password: String,
4651}
4652
4653async fn password_update(
4658 State(state): State<AdminState>,
4659 Extension(user): Extension<AuthenticatedUser>,
4660 Extension(session): Extension<session::SessionHandle>,
4661 Extension(ctx): Extension<RequestContext>,
4662 jar: CookieJar,
4663 Form(form): Form<PasswordForm>,
4664) -> Response {
4665 let back = format!("{}/preferences#password", state.admin_path);
4666 let refuse = |message: Text| {
4667 session.push_flash(FlashLevel::Error, message);
4668 Redirect::to(&back).into_response()
4669 };
4670 if form.new_password != form.confirm_password {
4671 return refuse(t!("The new passwords do not match."));
4672 }
4673 let min_length = state.auth.password_policy().min_length;
4674 if form.new_password.chars().count() < min_length {
4675 return refuse(t!(
4676 "A password needs at least {n} characters.",
4677 n = min_length as i64
4678 ));
4679 }
4680 let remembered_here = match jar.get(REMEMBER_COOKIE) {
4683 Some(cookie) => {
4684 state
4685 .auth
4686 .remember_holder(cookie.value())
4687 .await
4688 .ok()
4689 .flatten()
4690 == Some(user.user.id)
4691 }
4692 None => false,
4693 };
4694 let token = jar
4695 .get(SESSION_COOKIE)
4696 .map(|c| c.value().to_string())
4697 .unwrap_or_default();
4698 let changed = state
4699 .auth
4700 .change_own_password(
4701 user.user.id,
4702 &form.current_password,
4703 &form.new_password,
4704 &token,
4705 &ctx,
4706 )
4707 .await;
4708 match changed {
4709 Ok(()) => {}
4710 Err(laterite_auth::AuthError::InvalidCredentials) => {
4711 return refuse(t!("Your current password is not correct."));
4712 }
4713 Err(laterite_auth::AuthError::TooManyAttempts) => {
4714 return refuse(t!("Too many attempts. Try again later."));
4715 }
4716 Err(e) => {
4717 tracing::error!(error = %e, "changing a password failed");
4718 return refuse(t!("The password could not be changed."));
4719 }
4720 }
4721 session.push_flash_sticky(
4722 FlashLevel::Success,
4723 t!("Password changed. Every other device was signed out."),
4724 );
4725 let jar = if remembered_here {
4726 match state.auth.issue_remember(user.user.id).await {
4727 Ok(credential) => jar.add(remember_cookie(
4728 credential,
4729 &state.cookie_path,
4730 state.secure_cookie,
4731 )),
4732 Err(e) => {
4733 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
4734 jar.remove(remember_removal(&state.cookie_path))
4735 }
4736 }
4737 } else if jar.get(REMEMBER_COOKIE).is_some() {
4738 jar.remove(remember_removal(&state.cookie_path))
4741 } else {
4742 jar
4743 };
4744 (
4745 jar,
4746 Redirect::to(&format!("{}/preferences", state.admin_path)),
4747 )
4748 .into_response()
4749}
4750
4751#[derive(Deserialize)]
4752struct PreferencesForm {
4753 timezone: String,
4754 #[serde(default)]
4756 locale: String,
4757}
4758
4759async fn preferences_update(
4760 State(state): State<AdminState>,
4761 Extension(user): Extension<AuthenticatedUser>,
4762 Extension(session): Extension<session::SessionHandle>,
4763 Form(form): Form<PreferencesForm>,
4764) -> Response {
4765 let back = format!("{}/preferences", state.admin_path);
4766 let offered = offered_locales(&state.catalogs);
4767 let tz = form.timezone.trim();
4769 let tz_stored = if tz.is_empty() {
4770 None
4771 } else if tz.parse::<Tz>().is_ok() {
4772 Some(tz)
4773 } else {
4774 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
4775 return Redirect::to(&back).into_response();
4776 };
4777 let loc = form.locale.trim();
4778 let loc_stored = if loc.is_empty() {
4779 None
4780 } else if offered.iter().any(|l| l == loc) {
4781 Some(loc)
4782 } else {
4783 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
4784 return Redirect::to(&back).into_response();
4785 };
4786 if state
4787 .auth
4788 .set_user_timezone(user.user.id, tz_stored)
4789 .await
4790 .is_err()
4791 {
4792 return render_error();
4793 }
4794 match state.auth.set_user_locale(user.user.id, loc_stored).await {
4795 Ok(()) => {
4796 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
4797 Redirect::to(&back).into_response()
4798 }
4799 Err(_) => render_error(),
4800 }
4801}
4802
4803#[allow(clippy::too_many_arguments)]
4807fn preferences_view(
4808 shell: &Shell,
4809 user: &AuthenticatedUser,
4810 default_tz: Tz,
4811 default_locale: &str,
4812 offered: &[String],
4813 account: AccountSecurity,
4814 min_password: usize,
4815 items: Vec<PreferenceLink>,
4816 menu_entries: Vec<MenuEntryView>,
4817) -> PreferencesTemplate {
4818 let current = user.user.timezone.as_deref();
4819 let zones = TZ_VARIANTS
4820 .iter()
4821 .filter(|tz| zone_offered(tz.name()))
4822 .map(|tz| TzOption {
4823 name: tz.name().to_string(),
4824 selected: current == Some(tz.name()),
4825 })
4826 .collect();
4827 let current_locale = user.user.locale.as_deref();
4828 let locales = offered
4829 .iter()
4830 .map(|code| LocaleOption {
4831 name: locale_name(code),
4832 selected: current_locale == Some(code.as_str()),
4833 code: code.clone(),
4834 })
4835 .collect();
4836 PreferencesTemplate {
4837 shell: shell.clone(),
4838 zones,
4839 effective_tz: shell.tz.name().to_string(),
4840 default_tz: default_tz.name().to_string(),
4841 inherits: current.is_none(),
4842 locales,
4843 default_locale: locale_name(default_locale),
4844 inherits_locale: current_locale.is_none(),
4845 sessions: account.sessions,
4846 password_changed: account.password_changed,
4847 must_change: account.must_change,
4848 min_password: min_password as i64,
4849 items,
4850 menu_entries,
4851 }
4852}
4853
4854struct AccountSecurity {
4857 sessions: Vec<SessionRow>,
4858 password_changed: Option<String>,
4859 must_change: bool,
4861}
4862
4863async fn account_security(
4864 state: &AdminState,
4865 user: &AuthenticatedUser,
4866 shell: &Shell,
4867 jar: &CookieJar,
4868) -> AccountSecurity {
4869 let locale = list::date_locale(shell.locale());
4870 let password_changed = match state.auth.password_changed_at(user.user.id).await {
4871 Ok(at) => {
4872 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
4873 }
4874 Err(e) => {
4875 tracing::error!(error = %e, "reading the password change time failed");
4876 None
4877 }
4878 };
4879 AccountSecurity {
4880 sessions: session_rows(state, user, shell, jar).await,
4881 password_changed,
4882 must_change: state
4883 .auth
4884 .must_change_password(user.user.id)
4885 .await
4886 .unwrap_or(false),
4887 }
4888}
4889
4890async fn session_rows(
4893 state: &AdminState,
4894 user: &AuthenticatedUser,
4895 shell: &Shell,
4896 jar: &CookieJar,
4897) -> Vec<SessionRow> {
4898 let token = jar
4899 .get(SESSION_COOKIE)
4900 .map(|c| c.value().to_string())
4901 .unwrap_or_default();
4902 let locale = list::date_locale(shell.locale());
4903 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
4904 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
4905 };
4906 match state.auth.list_sessions(user.user.id, &token).await {
4907 Ok(sessions) => sessions
4908 .into_iter()
4909 .map(|s| SessionRow {
4910 id: s.id,
4911 started: stamp(s.created_at),
4912 last_seen: stamp(s.last_seen_at),
4913 expires: stamp(s.expires_at),
4914 device: clientip::describe(s.user_agent.as_deref())
4915 .or(s.user_agent)
4916 .unwrap_or_else(|| "-".to_string()),
4917 ip_address: s.ip_address.unwrap_or_default(),
4918 current: s.current,
4919 })
4920 .collect(),
4921 Err(e) => {
4922 tracing::error!(error = %e, "listing sessions failed");
4923 Vec::new()
4924 }
4925 }
4926}
4927
4928fn builtin_resources() -> Vec<Resource> {
4930 vec![
4931 Resource {
4932 base_path: "/users".to_string(),
4933 nav_label: "Administrators".into(),
4934 list: backend_users_list_config(),
4935 form: None,
4936 permission: Some("backend.manage_users".to_string()),
4937 import: None,
4938 palette: None,
4939 relations: Vec::new(),
4940 menu: nav::Placement::default(),
4941 },
4942 Resource {
4943 base_path: "/roles".to_string(),
4944 nav_label: "Roles".into(),
4945 list: roles_list_config(),
4946 form: None,
4949 permission: Some("backend.manage_roles".to_string()),
4950 import: None,
4951 palette: None,
4952 relations: Vec::new(),
4953 menu: nav::Placement::default(),
4954 },
4955 resource!("descriptors/audit-log.yaml"),
4956 resource!("descriptors/access-log.yaml"),
4957 resource!("descriptors/request-log.yaml"),
4958 ]
4959}
4960
4961fn builtin_settings() -> Vec<settings::SettingsItem> {
4965 vec![
4966 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
4967 .description("Manage backend administrator accounts.")
4968 .category("Users")
4969 .slot(laterite_core::arrange::slot::PEOPLE)
4970 .order(10)
4971 .icon("users")
4972 .permission("backend.manage_users")
4973 .link("/users"),
4974 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
4975 .description("Manage roles and their permissions.")
4976 .category("Users")
4977 .slot(laterite_core::arrange::slot::PEOPLE)
4978 .order(20)
4979 .icon("shield")
4980 .permission("backend.manage_roles")
4981 .link("/roles"),
4982 settings::brand::settings_item(),
4983 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
4984 .description("Enable or disable installed plugins.")
4985 .category("System")
4986 .slot(laterite_core::arrange::slot::SYSTEM)
4987 .order(10)
4988 .icon("plug")
4989 .permission(plugins::MANAGE_PERMISSION)
4990 .link("/plugins"),
4991 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
4992 .description("Review the record of administrative changes.")
4993 .category("System")
4994 .slot(laterite_core::arrange::slot::SYSTEM)
4995 .order(20)
4996 .icon("history")
4997 .permission("backend.view_audit_log")
4998 .link("/audit-log"),
4999 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
5000 .description("Review sign-ins, failures and lockouts.")
5001 .category("System")
5002 .slot(laterite_core::arrange::slot::SYSTEM)
5003 .order(30)
5004 .icon("log-in")
5005 .permission("backend.view_access_log")
5006 .link("/access-log"),
5007 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
5008 .description("Review the URLs the panel could not serve.")
5009 .category("System")
5010 .slot(laterite_core::arrange::slot::SYSTEM)
5011 .order(31)
5012 .icon("unlink")
5013 .permission("backend.view_request_log")
5014 .link("/request-log"),
5015 ]
5016}
5017
5018fn backend_users_list_config() -> list::ListConfig {
5019 list::ListConfig {
5020 entity: "backend_users".to_string(),
5021 title: "Administrators".into(),
5022 columns: vec![
5023 list::ListColumn::new("username", "Username"),
5024 list::ListColumn::new("email", "Email"),
5025 list::ListColumn::new("first_name", "First name"),
5026 list::ListColumn::new("last_name", "Last name"),
5027 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
5028 list::ListColumn::new("is_active", "Active").yes_no(),
5029 list::ListColumn::new("created_at", "Created").datetime(),
5030 ],
5031 order_by: "created_at".to_string(),
5032 creatable: true,
5035 edit_base: Some("/users".to_string()),
5036 filters: vec![
5037 list::ListFilter::boolean("is_active", "Active"),
5038 list::ListFilter::boolean("is_superuser", "Superuser"),
5039 ],
5040 ..Default::default()
5044 }
5045}
5046
5047fn roles_list_config() -> list::ListConfig {
5048 list::ListConfig {
5049 entity: "backend_roles".to_string(),
5050 title: "Roles".into(),
5051 columns: vec![
5052 list::ListColumn::new("code", "Code"),
5053 list::ListColumn::new("name", "Name"),
5054 list::ListColumn::new("created_at", "Created").datetime(),
5055 ],
5056 order_by: "created_at".to_string(),
5057 edit_base: Some("/roles".to_string()),
5058 creatable: true,
5059 deletable: true,
5060 ..Default::default()
5061 }
5062}
5063
5064#[derive(Template)]
5065#[template(path = "login.html")]
5066struct LoginTemplate {
5067 base: String,
5069 asset_urls: Arc<AssetUrls>,
5071 brand: String,
5072 look: Arc<settings::brand::Brand>,
5073 skin: Arc<skin::Active>,
5074 error: Option<String>,
5075 notice: Option<String>,
5077 i18n: Translator,
5080}
5081
5082fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
5085 match urls.get(key) {
5086 Some(path) => format!("{base}/assets/{path}"),
5087 None => {
5088 debug_assert!(false, "asset key `{key}` is not registered");
5089 format!("{base}/assets/{key}")
5090 }
5091 }
5092}
5093
5094impl LoginTemplate {
5095 fn asset(&self, key: &str) -> String {
5096 preauth_asset(&self.asset_urls, &self.base, key)
5097 }
5098 fn t(&self, source: &str) -> String {
5099 self.i18n.t(&Text::dynamic(source))
5100 }
5101 fn locale(&self) -> &str {
5102 self.i18n.locale()
5103 }
5104}
5105
5106#[derive(Template)]
5107#[template(path = "dashboard.html")]
5108struct DashboardTemplate {
5109 shell: Shell,
5110 widgets: Vec<String>,
5112 available: Vec<AvailableWidgetView>,
5113}
5114
5115#[derive(Template)]
5116#[template(path = "setup.html")]
5117struct SetupTemplate {
5118 base: String,
5120 asset_urls: Arc<AssetUrls>,
5122 brand: String,
5123 look: Arc<settings::brand::Brand>,
5124 skin: Arc<skin::Active>,
5125 zones: Vec<TzOption>,
5126 error: Option<String>,
5127 i18n: Translator,
5129 min_password: i64,
5131}
5132
5133impl SetupTemplate {
5134 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
5137 let mut text = Text::dynamic(source);
5138 for (name, value) in args {
5139 text = text.arg(*name, *value);
5140 }
5141 self.i18n.t(&text)
5142 }
5143
5144 fn asset(&self, key: &str) -> String {
5145 preauth_asset(&self.asset_urls, &self.base, key)
5146 }
5147 fn t(&self, source: &str) -> String {
5148 self.i18n.t(&Text::dynamic(source))
5149 }
5150 fn locale(&self) -> &str {
5151 self.i18n.locale()
5152 }
5153}
5154
5155#[derive(Template)]
5156#[template(path = "preferences.html")]
5157struct PreferencesTemplate {
5158 shell: Shell,
5159 zones: Vec<TzOption>,
5160 effective_tz: String,
5162 default_tz: String,
5164 inherits: bool,
5166 locales: Vec<LocaleOption>,
5167 default_locale: String,
5169 inherits_locale: bool,
5171 sessions: Vec<SessionRow>,
5173 password_changed: Option<String>,
5175 must_change: bool,
5177 min_password: i64,
5179 items: Vec<PreferenceLink>,
5181 menu_entries: Vec<MenuEntryView>,
5183}
5184
5185struct MenuEntryView {
5188 key: String,
5189 label: String,
5190 home: &'static str,
5192 chosen: String,
5194}
5195
5196struct SessionRow {
5198 id: String,
5199 started: String,
5200 last_seen: String,
5201 expires: String,
5202 device: String,
5205 ip_address: String,
5207 current: bool,
5208}
5209
5210struct TzOption {
5211 name: String,
5212 selected: bool,
5213}
5214
5215struct LocaleOption {
5216 code: String,
5218 name: String,
5220 selected: bool,
5221}
5222
5223#[derive(Clone)]
5225struct CrumbView {
5226 label: String,
5227 href: String,
5228}
5229
5230#[derive(Clone)]
5231struct NavView {
5232 key: String,
5234 label: String,
5235 path: String,
5236 active: bool,
5237 icon: String,
5240}
5241
5242pub(crate) fn render<T: Template>(template: T) -> Response {
5244 match template.render() {
5245 Ok(html) => Html(html).into_response(),
5246 Err(e) => AdminError::from(e).into_response(),
5248 }
5249}
5250
5251pub(crate) fn render_error() -> Response {
5252 error::masked_500()
5253}
5254
5255pub(crate) fn not_found() -> Response {
5256 AdminError::NotFound.into_response()
5257}
5258
5259pub(crate) fn forbidden() -> Response {
5260 AdminError::Forbidden.into_response()
5261}
5262
5263#[cfg(test)]
5264mod tests {
5265 use super::*;
5266
5267 fn write_form() -> form::FormConfig {
5268 form::FormConfig {
5269 enter: Default::default(),
5270 confirm_leave: true,
5271 focus: Default::default(),
5272 entity: "widgets".to_string(),
5273 title: "Widgets".into(),
5274 base_path: "/widgets".to_string(),
5275 id_field: "id".to_string(),
5276 fields: vec![],
5277 persist: None,
5278 timestamps: false,
5279 done_path: None,
5280 }
5281 }
5282
5283 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
5284 Resource {
5285 base_path: "/widgets".to_string(),
5286 nav_label: "Widgets".into(),
5287 list: backend_users_list_config(),
5288 form,
5289 permission: permission.map(str::to_string),
5290 import: None,
5291 palette: None,
5292 relations: Vec::new(),
5293 menu: nav::Placement::default(),
5294 }
5295 }
5296
5297 #[test]
5298 #[should_panic(expected = "must declare a permission")]
5299 fn write_resource_without_permission_aborts_boot() {
5300 let resource = resource_with(Some(write_form()), None);
5303 let _ = mount_resource(
5304 &resource,
5305 &field::builtin_registry(),
5306 &list::builtin_column_registry(),
5307 &std::collections::HashSet::new(),
5308 &persist::PersisterRegistry::new(),
5309 &[],
5310 );
5311 }
5312
5313 #[test]
5315 fn the_access_log_is_read_from_its_descriptor_file() {
5316 let access = builtin_resources()
5317 .into_iter()
5318 .find(|r| r.base_path == "/access-log")
5319 .expect("the access log is registered");
5320 assert_eq!(access.list.entity, "backend_access_log");
5321 assert_eq!(
5322 access.permission.as_deref(),
5323 Some("backend.view_access_log")
5324 );
5325 assert!(access.form.is_none(), "append-only");
5326 assert_eq!(
5327 access
5328 .list
5329 .columns
5330 .iter()
5331 .map(|c| c.field.as_str())
5332 .collect::<Vec<_>>(),
5333 [
5334 "created_at",
5335 "username_attempted",
5336 "event",
5337 "ip_address",
5338 "user_agent"
5339 ]
5340 );
5341 }
5342
5343 #[test]
5346 fn the_audit_log_is_read_from_its_descriptor_file() {
5347 let audit = builtin_resources()
5348 .into_iter()
5349 .find(|r| r.base_path == "/audit-log")
5350 .expect("the audit log is registered");
5351 assert_eq!(audit.list.entity, "backend_audit_log");
5352 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
5353 assert_eq!(
5354 audit
5355 .list
5356 .columns
5357 .iter()
5358 .map(|c| c.field.as_str())
5359 .collect::<Vec<_>>(),
5360 [
5361 "created_at",
5362 "actor_username",
5363 "action",
5364 "target_type",
5365 "target_id",
5366 "target_label"
5367 ],
5368 "in the order the file writes them"
5369 );
5370 assert_eq!(audit.list.columns[0].column_type, "datetime");
5371 assert_eq!(audit.list.per_page, 50);
5372 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
5373 assert!(audit.list.exportable, "evidence may leave the panel");
5374 assert!(audit.form.is_none(), "append-only: no form");
5375 assert!(!audit.list.deletable, "and nothing removes from it");
5376 }
5377
5378 #[test]
5379 #[should_panic(expected = "uses unregistered type `nope`")]
5380 fn unregistered_column_type_aborts_boot() {
5381 let mut resource = resource_with(None, None);
5382 let mut score = list::ListColumn::new("score", "Score");
5383 score.column_type = "nope".into();
5384 resource.list.columns.push(score);
5385 let _ = mount_resource(
5386 &resource,
5387 &field::builtin_registry(),
5388 &list::builtin_column_registry(),
5389 &std::collections::HashSet::new(),
5390 &persist::PersisterRegistry::new(),
5391 &[],
5392 );
5393 }
5394
5395 #[test]
5396 fn read_only_resource_may_omit_permission() {
5397 let resource = resource_with(None, None);
5399 let _ = mount_resource(
5400 &resource,
5401 &field::builtin_registry(),
5402 &list::builtin_column_registry(),
5403 &std::collections::HashSet::new(),
5404 &persist::PersisterRegistry::new(),
5405 &[],
5406 );
5407 }
5408
5409 #[test]
5410 fn shell_localizes_through_the_translator_and_falls_back() {
5411 let shell = Shell::test();
5413 assert_eq!(shell.t("Save"), "Save");
5414 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
5415 }
5416
5417 #[test]
5418 fn shell_tf_interpolates_integer_arguments() {
5419 let shell = Shell::test();
5420 assert_eq!(
5421 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
5422 "Page 2 of 5"
5423 );
5424 }
5425
5426 #[test]
5427 fn display_tz_prefers_a_valid_operator_preference() {
5428 assert_eq!(
5429 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
5430 Tz::Asia__Kolkata
5431 );
5432 }
5433
5434 #[test]
5435 fn display_tz_falls_back_when_unset_or_invalid() {
5436 let default = Tz::Europe__London;
5437 assert_eq!(resolve_display_tz(None, default), default);
5439 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
5441 assert_eq!(resolve_display_tz(Some(""), default), default);
5442 }
5443
5444 #[test]
5445 fn accept_language_orders_by_weight_and_drops_wildcard() {
5446 assert_eq!(
5447 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
5448 vec!["en-US", "en", "kn"]
5449 );
5450 assert_eq!(
5452 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
5453 vec!["kn", "en"]
5454 );
5455 }
5456
5457 #[test]
5458 fn locale_chain_prefers_operator_then_header_then_default() {
5459 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
5461 assert_eq!(
5464 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
5465 vec!["kn", "ta", "hi", "en"]
5466 );
5467 assert_eq!(
5469 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
5470 vec!["kn", "en", "ta"]
5471 );
5472 assert_eq!(
5474 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
5475 vec!["kn", "en"]
5476 );
5477 }
5478
5479 #[test]
5480 fn locale_chain_skips_unserveable_and_defaults_to_en() {
5481 let loaded = ["hi".to_string()];
5483 assert_eq!(
5486 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
5487 vec!["en"]
5488 );
5489 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
5491 assert_eq!(
5493 resolve_locale_chain(Some("hi"), None, "en", &loaded),
5494 vec!["hi", "en"]
5495 );
5496 assert_eq!(
5498 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
5499 vec![laterite_core::PSEUDO_LOCALE, "en"]
5500 );
5501 }
5502
5503 #[test]
5504 fn a_page_with_an_aside_renders_the_fold_beside_the_body() {
5505 use askama::Template;
5506 #[derive(askama::Template)]
5507 #[template(source = "<p>{{ text }}</p>", ext = "html")]
5508 struct Para {
5509 text: &'static str,
5510 }
5511 let para = |text| html::Markup::from_template(&Para { text }).unwrap();
5512 let page = PageTemplate {
5513 title: "Spec".to_string(),
5514 back: None,
5515 actions: None,
5516 aside: Some(("Chapters".to_string(), para("one"))),
5517 body: para("body"),
5518 shell: Shell::test(),
5519 };
5520 let html = page.render().unwrap();
5521 let panel = html.find("<aside class=\"lat-aside\"").expect("aside");
5522 let body = html.find("<p>body</p>").expect("body");
5523 assert!(panel < body, "the panel comes before the body: {html}");
5524 assert!(html.contains("data-lat-widget=\"aside\""));
5525 assert!(html.contains("<summary class=\"lat-aside__summary\">Chapters</summary>"));
5526 assert!(html.contains("aria-label=\"Chapters\""));
5527 let plain = PageTemplate {
5528 title: "Spec".to_string(),
5529 back: None,
5530 actions: None,
5531 aside: None,
5532 body: para("body"),
5533 shell: Shell::test(),
5534 };
5535 assert!(!plain.render().unwrap().contains("lat-aside"));
5536 }
5537
5538 #[test]
5539 fn page_head_assets_dedupe_and_keep_order() {
5540 let head = PageHead::new("Specification")
5541 .assets(CODE_EDITOR_ASSETS)
5542 .assets(&["fields/codeeditor.js", "fields/ref-picker.js"]);
5543 assert_eq!(
5544 head.assets,
5545 vec![
5546 "vendor/codeeditor.js",
5547 "fields/codeeditor.js",
5548 "fields/codeeditor.css",
5549 "fields/ref-picker.js"
5550 ]
5551 );
5552 }
5553
5554 #[test]
5555 fn page_assets_dedupe_preserve_order_and_classify() {
5556 let mut reg = AssetRegistry::new();
5557 reg.insert(
5558 "a.js",
5559 AdminAsset {
5560 mime: "text/javascript; charset=utf-8",
5561 bytes: std::borrow::Cow::Borrowed(b"//"),
5562 },
5563 );
5564 reg.insert(
5565 "b.css",
5566 AdminAsset {
5567 mime: "text/css; charset=utf-8",
5568 bytes: std::borrow::Cow::Borrowed(b"body{}"),
5569 },
5570 );
5571 let urls = asset_urls(®);
5574 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
5575 assert_eq!(assets.len(), 2);
5576 assert_eq!(
5577 assets[0].url,
5578 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
5579 );
5580 assert!(!assets[0].css);
5581 assert_eq!(
5582 assets[1].url,
5583 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
5584 );
5585 assert!(assets[1].css);
5586 assert_ne!(assets[0].url, assets[1].url);
5589 }
5590
5591 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
5595 laterite_core::testing::connect_test(&[]).await
5596 }
5597
5598 #[tokio::test]
5599 async fn builtin_migrations_create_the_admin_tables() {
5600 let (db, _guard) = empty_db().await;
5601 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
5602 .await
5603 .unwrap();
5604 for table in ["backend_users", "settings"] {
5608 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
5609 .fetch_optional(&db.pool)
5610 .await;
5611 assert!(
5612 probe.is_ok(),
5613 "{table} should exist after builtin_migrations"
5614 );
5615 }
5616 }
5617
5618 #[tokio::test]
5619 async fn brand_setting_overrides_config_and_blank_falls_back() {
5620 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
5621 let state = AdminState {
5622 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
5623 db: db.clone(),
5624 parameters: laterite_core::parameters::Parameters::new(db.clone()),
5625 upload_extensions: Arc::new(upload_extensions(&[])),
5626 nav: Arc::new(nav::NavTree::default()),
5627 nav_items: Arc::new(Vec::new()),
5628 settings: Arc::new(Vec::new()),
5629 permissions: Arc::new(builtin_permissions()),
5630 trusted_proxies: Arc::new(Vec::new()),
5631 icons: Arc::new(laterite_core::icons::IconSet::new()),
5632 sprite_url: Arc::from(""),
5633 admin_path: Arc::from("/admin"),
5634 cookie_path: Arc::from("/admin"),
5635 storage_dir: Arc::from("storage"),
5636 crypt: Arc::new(laterite_core::Crypt::ephemeral()),
5637 order: std::collections::BTreeMap::new(),
5638 secure_cookie: false,
5639 origin: Arc::from(""),
5640 timezone: Tz::UTC,
5641 default_locale: Arc::from("en"),
5642 catalogs: Arc::new(CatalogStore::default()),
5643 app_name: "Configured Name".to_string(),
5644 brand_cache: Arc::new(RwLock::new(None)),
5645 skin: Arc::new(skin::Active::default()),
5646 field_types: Arc::new(field::builtin_registry()),
5647 column_types: Arc::new(list::builtin_column_registry()),
5648 widgets: Arc::new(widgets::builtin()),
5649 plugin_defined: Arc::new(laterite_core::Registry::new()),
5650 assets: Arc::new(builtin_assets()),
5651 asset_urls: Arc::new(asset_urls(&builtin_assets())),
5652 pickers: Arc::new(picker::PickerRegistry::new()),
5653 palette: Arc::new(Vec::new()),
5654 overrides: Arc::new(field::NoOverrides),
5655 };
5656
5657 assert_eq!(state.brand().await.name, "Configured Name");
5659
5660 settings::store::save(
5663 &db,
5664 &settings::BrandSetting {
5665 app_name: "Acme Corp".to_string(),
5666 ..Default::default()
5667 },
5668 )
5669 .await
5670 .unwrap();
5671 state.invalidate_brand();
5672 assert_eq!(state.brand().await.name, "Acme Corp");
5673
5674 settings::store::save(
5676 &db,
5677 &settings::BrandSetting {
5678 app_name: " ".to_string(),
5679 ..Default::default()
5680 },
5681 )
5682 .await
5683 .unwrap();
5684 state.invalidate_brand();
5685 assert_eq!(state.brand().await.name, "Configured Name");
5686 }
5687
5688 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
5689 settings::SettingsItem {
5690 code: code.to_string(),
5691 label: code.into(),
5692 description: String::new().into(),
5693 category: "General".into(),
5694 hint: None,
5695 confirm_leave: true,
5696 slot: laterite_core::arrange::slot::APPLICATION,
5697 order: 1,
5698 icon: None,
5699 permission: permission.map(str::to_string),
5700 link: None,
5701 fields: Vec::new(),
5702 route: None,
5703 per_operator: false,
5704 }
5705 }
5706
5707 #[test]
5708 fn settings_visibility_respects_permissions() {
5709 let items = vec![
5710 settings_item("public", None),
5711 settings_item("gated", Some("backend.manage_users")),
5712 ];
5713
5714 let none = PermissionSet::new(false, Vec::<String>::new());
5716 let codes: Vec<String> = visible_settings(&items, &none)
5717 .into_iter()
5718 .map(|i| i.code)
5719 .collect();
5720 assert_eq!(codes, ["public"]);
5721
5722 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
5724 assert_eq!(visible_settings(&items, &granted).len(), 2);
5725
5726 let superuser = PermissionSet::new(true, Vec::<String>::new());
5728 assert_eq!(visible_settings(&items, &superuser).len(), 2);
5729 }
5730
5731 #[test]
5732 fn context_sidebar_follows_settings_links() {
5733 let items = builtin_settings();
5734 let superuser = PermissionSet::new(true, Vec::<String>::new());
5735 let tr = Translator::new("en");
5736 let tree = nav::NavTree::default();
5737 let sidebar = |path: &str| {
5738 resolve_nav_context(
5739 &tree,
5740 &items,
5741 "/admin",
5742 &superuser,
5743 None,
5744 path,
5745 &tr,
5746 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
5747 &std::collections::BTreeMap::new(),
5748 None,
5749 )
5750 .1
5751 };
5752 let active_path = |path: &str| -> Option<String> {
5753 sidebar(path)
5754 .into_iter()
5755 .flat_map(|g| g.items)
5756 .find(|i| i.active)
5757 .map(|i| i.path)
5758 };
5759
5760 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
5762 assert_eq!(
5763 active_path("/admin/roles/42/edit").as_deref(),
5764 Some("/admin/roles")
5765 );
5766 assert!(!sidebar("/admin/settings").is_empty());
5768 assert_eq!(active_path("/admin/settings"), None);
5769 assert_eq!(
5774 active_path("/admin/settings/branding").as_deref(),
5775 Some("/admin/settings/branding")
5776 );
5777 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
5780 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
5781 assert!(sidebar("/admin").is_empty());
5783 }
5784}
5785
5786#[cfg(test)]
5787mod session_cookie_tests {
5788 use super::*;
5789
5790 #[test]
5794 fn the_session_cookie_never_outlives_the_browser() {
5795 let cookie = session_cookie("tok".into(), "/admin", false);
5796 assert_eq!(cookie.max_age(), None);
5797 assert!(cookie.http_only().unwrap_or(false));
5798 assert_eq!(cookie.path(), Some("/admin"));
5799 }
5800
5801 #[test]
5804 fn the_remember_cookie_carries_its_own_lifetime() {
5805 let credential = laterite_auth::RememberCredential {
5806 cookie: "sel:ver".into(),
5807 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
5808 };
5809 let cookie = remember_cookie(credential, "/admin", true);
5810 let age = cookie.max_age().expect("a remember cookie must persist");
5811 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
5813 assert!(cookie.http_only().unwrap_or(false));
5814 assert!(cookie.secure().unwrap_or(false));
5815 }
5816
5817 #[test]
5819 fn the_removal_matches_the_cookie_it_clears() {
5820 let removal = remember_removal("/admin");
5821 assert_eq!(removal.name(), REMEMBER_COOKIE);
5822 assert_eq!(removal.path(), Some("/admin"));
5823 }
5824}