1use std::collections::{HashMap, HashSet};
11use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
12use std::path::PathBuf;
13
14use axum::Router;
15use laterite_auth::{AuthConfig, AuthService};
16use laterite_core::config::{self, AppMeta, BackendConfig, DatabaseConfig, ServerConfig};
17use laterite_core::{CapabilitySet, Db, Module, ModuleRegistry, Registry, Translator};
18use serde::Deserialize;
19
20use crate::settings::SettingsItem;
21use crate::{builtin_modules, normalize_path, router, AdminConfig, Permission, Resource};
22
23pub const DEFAULT_ENV_PREFIX: &str = "LAT";
27
28fn resolve_env_prefix(code: Option<String>, declared: Option<String>) -> String {
31 code.or(declared)
32 .unwrap_or_else(|| DEFAULT_ENV_PREFIX.to_string())
33}
34
35#[cfg(test)]
36mod env_prefix_tests {
37 use super::resolve_env_prefix;
38
39 #[test]
40 fn code_override_then_declaration_then_default() {
41 assert_eq!(resolve_env_prefix(None, None), "LAT");
42 assert_eq!(resolve_env_prefix(None, Some("ACME".into())), "ACME");
43 assert_eq!(
44 resolve_env_prefix(Some("CODE".into()), Some("ACME".into())),
45 "CODE"
46 );
47 }
48}
49
50#[derive(Debug, Clone, Deserialize)]
53pub struct AppConfig {
54 #[serde(default)]
55 pub app: AppMeta,
56 pub server: ServerConfig,
57 pub database: DatabaseConfig,
58 #[serde(default)]
59 pub auth: AuthConfig,
60 #[serde(default)]
61 pub backend: BackendConfig,
62 #[serde(default)]
63 pub plugins: PluginsConfig,
64 #[serde(default)]
65 pub cache: laterite_core::config::CacheConfig,
66}
67
68#[derive(Debug, Clone, Deserialize, Default)]
72pub struct PluginsConfig {
73 #[serde(default)]
75 pub disabled: Vec<String>,
76}
77
78pub struct BootstrapCtx {
81 db: Db,
82 capabilities: CapabilitySet,
83 translator: Translator,
84 events: laterite_core::Events,
85 cache: laterite_core::Cache,
86}
87
88impl BootstrapCtx {
89 pub fn db(&self) -> Db {
91 self.db.clone()
92 }
93
94 pub fn capabilities(&self) -> &CapabilitySet {
97 &self.capabilities
98 }
99
100 pub fn translator(&self) -> &Translator {
103 &self.translator
104 }
105
106 pub fn events(&self) -> &laterite_core::Events {
109 &self.events
110 }
111
112 pub fn cache(&self) -> &laterite_core::Cache {
115 &self.cache
116 }
117}
118
119type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
120
121pub struct Bootstrap {
123 config_dir: PathBuf,
124 env_prefix: Option<String>,
125 app_modules: Vec<Box<dyn Module>>,
126 plugin_modules: Vec<Box<dyn Module>>,
127 extend: Option<ExtendFn>,
128}
129
130impl Bootstrap {
131 pub fn new(config_dir: impl Into<PathBuf>) -> Self {
136 Self {
137 config_dir: config_dir.into(),
138 env_prefix: None,
139 app_modules: Vec::new(),
140 plugin_modules: Vec::new(),
141 extend: None,
142 }
143 }
144
145 pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
148 self.env_prefix = Some(prefix.into());
149 self
150 }
151
152 pub fn module(mut self, module: impl Module) -> Self {
157 self.app_modules.push(Box::new(module));
158 self
159 }
160
161 pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
166 self.plugin_modules.extend(modules);
167 self
168 }
169
170 pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
173 self.extend = Some(Box::new(f));
174 self
175 }
176
177 pub async fn serve(self) -> anyhow::Result<()> {
181 let _ = tracing_subscriber::fmt()
183 .with_env_filter(
184 tracing_subscriber::EnvFilter::try_from_default_env()
185 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
186 )
187 .try_init();
188
189 let env_prefix = resolve_env_prefix(
190 self.env_prefix.clone(),
191 config::declared_env_prefix(&self.config_dir)?,
192 );
193 let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
194 laterite_core::config::set_debug(config.app.debug);
196
197 let db = laterite_core::db::connect(&config.database).await?;
198
199 let plugin_ids: HashSet<String> = self
203 .plugin_modules
204 .iter()
205 .map(|m| m.id().to_string())
206 .collect();
207
208 laterite_core::migration::run(
213 &db.pool,
214 db.backend,
215 &[crate::plugins::PluginsModule.migrations()],
216 )
217 .await?;
218 crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
219
220 let fingerprint = crate::plugins::binary_fingerprint();
226 let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
227 for id in &reconcile.crashed {
228 eprintln!(
229 "Plugin '{id}' quarantined: {}.",
230 crate::plugins::CRASH_REASON
231 );
232 }
233 for id in &reconcile.cleared {
234 println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
235 }
236
237 let db_disabled = crate::plugins::disabled_ids(&db).await?;
238 let quarantined = crate::plugins::quarantined(&db).await?;
239
240 let mut registry = ModuleRegistry::new();
241 for module in builtin_modules() {
242 registry.register_boxed(module);
243 }
244 for module in self.app_modules {
245 registry.register_boxed(module);
246 }
247 for module in self.plugin_modules {
248 registry.register_boxed(module);
249 }
250 let capabilities =
251 laterite_core::capabilities::check(&db.pool, db.backend, ®istry).await?;
252
253 let ordered = registry.ordered()?;
257
258 let builtins: HashSet<String> = builtin_modules()
263 .iter()
264 .map(|m| m.id().to_string())
265 .collect();
266 let mut seed: HashMap<String, String> = HashMap::new();
267 for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
268 if builtins.contains(id) {
269 eprintln!("Ignoring request to disable built-in module '{id}'.");
270 } else {
271 seed.insert(id.clone(), "disabled".to_string());
272 }
273 }
274 for (id, reason) in &quarantined {
277 if !builtins.contains(id) {
278 seed.insert(id.clone(), format!("quarantined: {reason}"));
279 }
280 }
281 let skipped = skip_plan(&ordered, &seed);
282 report_plugins(&ordered, &skipped);
283 let active: Vec<&dyn Module> = ordered
284 .iter()
285 .copied()
286 .filter(|m| !skipped.contains_key(m.id().as_str()))
287 .collect();
288
289 let mut contributions = Registry::new();
294 let mut failed: HashMap<String, String> = HashMap::new();
295 for module in &active {
296 let mid = module.id();
297 let id = mid.as_str();
298 let eligible = plugin_ids.contains(id);
299
300 if let Some(dep) = module
301 .requires()
302 .iter()
303 .find(|r| failed.contains_key(r.as_str()))
304 {
305 let reason = format!("requires {}, which failed to load", dep.as_str());
306 println!("Plugin '{id}' off: {reason}");
307 failed.insert(id.to_string(), reason);
308 continue;
309 }
310
311 if eligible {
316 crate::plugins::mark_loading(&db, id, &fingerprint).await?;
317 }
318
319 match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
320 {
321 Ok(()) => {}
322 Err(e) if !eligible => return Err(e.into()),
323 Err(e) => {
324 let reason = format!("migration failed: {e}");
325 eprintln!("Plugin '{id}' quarantined: {reason}");
326 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
327 failed.insert(id.to_string(), reason);
328 continue;
329 }
330 }
331
332 contributions.set_owner(mid);
333 let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
334 match registered {
335 Ok(()) => {
338 if eligible {
339 crate::plugins::clear_loading(&db, id).await?;
340 }
341 }
342 Err(panic) => {
343 contributions.purge_owner(mid);
344 if !eligible {
345 resume_unwind(panic);
346 }
347 let reason = "register panicked".to_string();
348 eprintln!("Plugin '{id}' quarantined: {reason}");
349 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
350 failed.insert(id.to_string(), reason);
351 }
352 }
353 }
354 for module in &active {
361 let mid = module.id();
362 let id = mid.as_str();
363 if failed.contains_key(id) {
364 continue;
365 }
366 let cx = laterite_core::BootCx {
367 db: &db,
368 registry: &contributions,
369 };
370 match module.boot(&cx).await {
371 Ok(()) => {}
372 Err(e) if !plugin_ids.contains(id) => return Err(e.into()),
373 Err(e) => {
374 let reason = format!("boot failed: {e}");
375 eprintln!("Plugin '{id}' quarantined: {reason}");
376 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
377 contributions.purge_owner(mid);
378 failed.insert(id.to_string(), reason);
379 }
380 }
381 }
382 let active: Vec<&dyn Module> = active
383 .into_iter()
384 .filter(|m| !failed.contains_key(m.id().as_str()))
385 .collect();
386 let bases: HashMap<String, Option<&'static str>> = active
387 .iter()
388 .map(|m| (m.id().to_string(), m.admin_base()))
389 .collect();
390 let mut resources = Vec::new();
391 for (owner, mut resource) in contributions.take_owned::<Resource>() {
392 let base = bases.get(owner.as_str()).copied().flatten();
393 crate::rebase_resource(
394 &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
395 &mut resource,
396 );
397 resources.push((owner, resource));
398 }
399 let mut screens = Vec::new();
400 for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
401 let base = bases.get(owner.as_str()).copied().flatten();
402 reg.base_path =
403 crate::routemap::resolve(owner, ®.base_path, base, &config.backend.paths);
404 screens.push((owner, reg));
405 }
406
407 let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
408
409 let mut claims: Vec<(String, String)> = crate::builtin_resources()
412 .iter()
413 .map(|r| (r.base_path.clone(), "the framework".to_string()))
414 .collect();
415 claims.extend(
416 resources
417 .iter()
418 .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
419 );
420 claims.extend(
421 screens
422 .iter()
423 .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
424 );
425 crate::routemap::check_segments(&claims);
426 crate::routemap::check_collisions(&claims);
427 let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
428 for resource in &resources {
430 if let Some(form) = &resource.form {
431 for field in form.fields.iter().filter(|f| f.field_type == "sensitive") {
432 laterite_core::crypt::register_column(
433 &db,
434 &form.entity,
435 &field.name,
436 laterite_core::crypt::FIELD_PURPOSE,
437 )
438 .await?;
439 }
440 }
441 }
442 let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
443 crate::routemap::check_public(
446 &public
447 .iter()
448 .map(|(owner, r)| (r.path.clone(), owner.to_string()))
449 .collect::<Vec<_>>(),
450 &crate::normalize_path(&config.backend.path),
451 );
452 let public: Vec<crate::routes::PublicRouteReg> =
453 public.into_iter().map(|(_, r)| r).collect();
454 let mut settings = Vec::new();
459 for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
460 let declared = item
463 .code
464 .strip_prefix(&format!("{}.", owner.as_str()))
465 .unwrap_or(&item.code)
466 .replace('.', "/");
467 let base = bases.get(owner.as_str()).copied().flatten();
468 item.mount_at(crate::routemap::resolve(
469 owner,
470 &format!("/{declared}"),
471 base,
472 &config.backend.paths,
473 ));
474 settings.push((owner, item));
475 }
476 let settings_claims: Vec<(String, String)> = settings
479 .iter()
480 .filter(|(_, i)| i.link.is_none())
481 .map(|(owner, i)| (i.route(), owner.to_string()))
482 .collect();
483 crate::routemap::check_segments(&settings_claims);
484 let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
485 let cache = laterite_core::cache::from_config(&config.cache, &db, config.app.debug).await?;
488 for (owner, namespace) in contributions.take_owned::<laterite_core::cache::CacheNamespace>()
489 {
490 cache.allow(namespace.qualified(owner));
491 }
492 if config.cache.poll_seconds > 0 {
493 cache.start_polling(std::time::Duration::from_secs(config.cache.poll_seconds));
494 }
495 tracing::info!(store = cache.store(), "cache ready");
496 let permissions = contributions.take::<Permission>();
497 let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
498 let persisters = contributions.take::<crate::persist::PersisterReg>();
499 let listeners = contributions.take::<laterite_core::ModelListenerReg>();
500 let field_types = contributions.take::<crate::field::FieldTypeReg>();
501 let column_types = contributions.take::<crate::list::ColumnTypeReg>();
502 let nav = contributions.take::<crate::nav::NavItem>();
503 let resource_hooks = contributions.take::<crate::ResourceHook>();
504 let widgets = contributions.take::<crate::widget::WidgetReg>();
505 let skins = contributions.take::<crate::skin::Skin>();
506 let assets = contributions.take::<crate::AssetReg>();
507 let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
509
510 let mut bus = laterite_core::Events::builder(db.clone());
513 for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
514 bus = bus.registered(owner, listener);
515 }
516 let events = bus.build()?;
517
518 let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
519 tokio::spawn(purge_hourly(auth.clone()));
523 let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
524 let secret = signing_key(&db, config.backend.secret.as_deref()).await?;
525 let crypt = crypt_from_config(&config, &secret)?;
526 let admin_config = AdminConfig {
527 secure_cookie: config.backend.secure_cookie,
528 trusted_proxies: config.backend.trusted_proxies.clone(),
529 timezone: config.backend.timezone.clone(),
530 locale: config.app.locale.clone(),
531 app_name: config.app.name.clone(),
532 path: config.backend.path.clone(),
533 origin: origin.clone(),
534 storage_dir: config.backend.storage.clone(),
535 skin: config.backend.skin.clone(),
536 upload_extensions: config.backend.upload_extensions.clone(),
537 order: config.backend.order.clone(),
538 menu: config.backend.menu.clone(),
539 secret,
540 crypt: Some(std::sync::Arc::new(crypt)),
541 cache: Some(cache.clone()),
542 };
543 let mut catalogs = laterite_core::CatalogStore::builder();
547 for module in active
548 .iter()
549 .filter(|m| !failed.contains_key(m.id().as_str()))
550 {
551 for (locale, po) in module.catalogs() {
552 catalogs = catalogs.po(locale, po).map_err(|e| {
553 laterite_core::CoreError::Config(format!(
554 "module {}: {locale} catalog: {e}",
555 module.id().as_str()
556 ))
557 })?;
558 }
559 }
560
561 let renamed =
564 laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
565 .await?;
566 if renamed > 0 {
567 tracing::info!(rows = renamed, "rewrote renamed permission codes");
568 }
569
570 laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
574
575 let mut app = router(
576 auth,
577 db.clone(),
578 crate::Contributions {
579 nav,
580 resource_hooks,
581 widgets,
582 skins,
583 assets,
584 resources,
585 settings,
586 permissions,
587 picker_sources,
588 persisters,
589 listeners,
590 field_types,
591 column_types,
592 icons,
593 screens,
594 public_routes: public,
595 plugin_defined: std::sync::Arc::new(contributions),
599 },
600 admin_config,
601 std::sync::Arc::new(catalogs.build()),
602 );
603
604 if let Some(extend) = self.extend {
605 let ctx = BootstrapCtx {
606 db,
607 capabilities,
608 translator: Translator::new(&config.app.locale),
609 events,
610 cache,
611 };
612 app = extend(app, &ctx);
613 }
614
615 let listener = bind(&config.server.listen).await?;
616 let admin_path = normalize_path(&config.backend.path);
617 println!("{} on {origin}{admin_path}", config.app.name);
618 axum::serve(
619 listener,
620 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
621 )
622 .await?;
623 Ok(())
624 }
625}
626
627async fn purge_expired(auth: &AuthService) {
631 match auth.purge_expired().await {
632 Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
633 sessions = purged.sessions,
634 remember_tokens = purged.remember_tokens,
635 "purged expired sessions and credentials"
636 ),
637 Ok(_) => {}
638 Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
639 }
640}
641
642async fn purge_hourly(auth: AuthService) {
644 let mut ticks = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
645 ticks.tick().await;
647 loop {
648 ticks.tick().await;
649 purge_expired(&auth).await;
650 }
651}
652
653async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
654 use listenfd::ListenFd;
655 match ListenFd::from_env().take_tcp_listener(0)? {
656 Some(std_listener) => {
657 std_listener.set_nonblocking(true)?;
658 Ok(tokio::net::TcpListener::from_std(std_listener)?)
659 }
660 None => Ok(tokio::net::TcpListener::bind(listen).await?),
661 }
662}
663
664fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
670 let mut skipped: HashMap<String, String> = HashMap::new();
671 for module in ordered {
672 let id = module.id().as_str();
673 if let Some(reason) = seed.get(id) {
674 skipped.insert(id.to_string(), reason.clone());
675 continue;
676 }
677 if let Some(dep) = module
678 .requires()
679 .iter()
680 .find(|r| skipped.contains_key(r.as_str()))
681 {
682 skipped.insert(
683 id.to_string(),
684 format!("requires {}, which is off", dep.as_str()),
685 );
686 }
687 }
688 skipped
689}
690
691fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
694 if skipped.is_empty() {
695 return;
696 }
697 for module in ordered {
698 if let Some(reason) = skipped.get(module.id().as_str()) {
699 println!("Plugin '{}' off: {reason}", module.id());
700 }
701 }
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707 use laterite_core::ModuleId;
708
709 #[test]
710 fn env_prefix_is_unset_until_overridden_in_code() {
711 assert_eq!(Bootstrap::new("config").env_prefix, None);
712 assert_eq!(
713 Bootstrap::new("config")
714 .env_prefix("ACME")
715 .env_prefix
716 .as_deref(),
717 Some("ACME")
718 );
719 }
720
721 #[test]
722 fn app_config_loads_named_sections_and_falls_back_to_defaults() {
723 let dir = tempfile::tempdir().unwrap();
724 std::fs::write(
725 dir.path().join("default.toml"),
726 "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
727 [database]\nurl = \"postgres://x\"\n",
728 )
729 .unwrap();
730 let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
731 assert_eq!(cfg.app.name, "Acme");
732 assert_eq!(cfg.server.listen, "0.0.0.0:9000");
733 assert_eq!(cfg.database.url, "postgres://x");
734 assert_eq!(cfg.backend.path, "/admin");
735 assert!(!cfg.backend.secure_cookie);
736 assert!(cfg.app.url.is_none());
737 }
738
739 struct M(&'static str, &'static [ModuleId]);
740 impl Module for M {
741 fn id(&self) -> ModuleId {
742 ModuleId::new(self.0)
743 }
744 fn requires(&self) -> &'static [ModuleId] {
745 self.1
746 }
747 }
748
749 #[test]
750 fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
751 const NONE: [ModuleId; 0] = [];
752 const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
753 const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
754 let (a, b, c, d) = (
757 M("acme.a", &NONE),
758 M("acme.b", &REQ_A),
759 M("acme.c", &REQ_B),
760 M("acme.d", &NONE),
761 );
762 let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
763
764 let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
765 .into_iter()
766 .collect();
767 let skipped = skip_plan(&ordered, &seed);
768
769 assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
771 assert!(skipped["acme.b"].contains("requires acme.a"));
772 assert!(skipped["acme.c"].contains("requires acme.b"));
773 assert!(!skipped.contains_key("acme.d"));
774 }
775}
776
777const SIGNING_KEY: &str = "laterite.signing_key";
779
780pub fn crypt_from_config(
786 config: &AppConfig,
787 legacy: &[u8],
788) -> anyhow::Result<laterite_core::Crypt> {
789 match config
790 .app
791 .key
792 .as_deref()
793 .map(str::trim)
794 .filter(|k| !k.is_empty())
795 {
796 Some(key) => laterite_core::Crypt::from_config(key, &config.app.previous_keys)
797 .map_err(|e| anyhow::anyhow!("app.key: {e}")),
798 None => {
799 if !config.app.debug {
800 tracing::warn!(
801 "app.key is not set; the key derives from backend.secret or the stored key. Run `lat key generate`."
802 );
803 }
804 Ok(laterite_core::Crypt::new(
805 laterite_core::MasterKey::derived(0, legacy),
806 Vec::new(),
807 ))
808 }
809 }
810}
811
812pub async fn legacy_key_material(
815 db: &laterite_core::Db,
816 configured: Option<&str>,
817) -> anyhow::Result<Vec<u8>> {
818 signing_key(db, configured).await
819}
820
821async fn signing_key(db: &laterite_core::Db, configured: Option<&str>) -> anyhow::Result<Vec<u8>> {
822 if let Some(secret) = configured.map(str::trim).filter(|s| !s.is_empty()) {
823 return Ok(secret.as_bytes().to_vec());
824 }
825 if let Some(serde_json::Value::String(hex)) =
826 crate::settings::store::get(db, SIGNING_KEY).await?
827 {
828 if let Some(key) = crate::routes::unhex(&hex).filter(|k| k.len() >= 16) {
829 return Ok(key);
830 }
831 }
832 let key = crate::random_key();
833 let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
834 crate::settings::store::set(db, SIGNING_KEY, &serde_json::Value::String(hex)).await?;
835 Ok(key)
836}