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}
65
66#[derive(Debug, Clone, Deserialize, Default)]
70pub struct PluginsConfig {
71 #[serde(default)]
73 pub disabled: Vec<String>,
74}
75
76pub struct BootstrapCtx {
79 db: Db,
80 capabilities: CapabilitySet,
81 translator: Translator,
82 events: laterite_core::Events,
83}
84
85impl BootstrapCtx {
86 pub fn db(&self) -> Db {
88 self.db.clone()
89 }
90
91 pub fn capabilities(&self) -> &CapabilitySet {
94 &self.capabilities
95 }
96
97 pub fn translator(&self) -> &Translator {
100 &self.translator
101 }
102
103 pub fn events(&self) -> &laterite_core::Events {
106 &self.events
107 }
108}
109
110type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
111
112pub struct Bootstrap {
114 config_dir: PathBuf,
115 env_prefix: Option<String>,
116 app_modules: Vec<Box<dyn Module>>,
117 plugin_modules: Vec<Box<dyn Module>>,
118 extend: Option<ExtendFn>,
119}
120
121impl Bootstrap {
122 pub fn new(config_dir: impl Into<PathBuf>) -> Self {
127 Self {
128 config_dir: config_dir.into(),
129 env_prefix: None,
130 app_modules: Vec::new(),
131 plugin_modules: Vec::new(),
132 extend: None,
133 }
134 }
135
136 pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
139 self.env_prefix = Some(prefix.into());
140 self
141 }
142
143 pub fn module(mut self, module: impl Module) -> Self {
148 self.app_modules.push(Box::new(module));
149 self
150 }
151
152 pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
157 self.plugin_modules.extend(modules);
158 self
159 }
160
161 pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
164 self.extend = Some(Box::new(f));
165 self
166 }
167
168 pub async fn serve(self) -> anyhow::Result<()> {
172 let _ = tracing_subscriber::fmt()
174 .with_env_filter(
175 tracing_subscriber::EnvFilter::try_from_default_env()
176 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
177 )
178 .try_init();
179
180 let env_prefix = resolve_env_prefix(
181 self.env_prefix.clone(),
182 config::declared_env_prefix(&self.config_dir)?,
183 );
184 let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
185 laterite_core::config::set_debug(config.app.debug);
187
188 let db = laterite_core::db::connect(&config.database).await?;
189
190 let plugin_ids: HashSet<String> = self
194 .plugin_modules
195 .iter()
196 .map(|m| m.id().to_string())
197 .collect();
198
199 laterite_core::migration::run(
204 &db.pool,
205 db.backend,
206 &[crate::plugins::PluginsModule.migrations()],
207 )
208 .await?;
209 crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
210
211 let fingerprint = crate::plugins::binary_fingerprint();
217 let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
218 for id in &reconcile.crashed {
219 eprintln!(
220 "Plugin '{id}' quarantined: {}.",
221 crate::plugins::CRASH_REASON
222 );
223 }
224 for id in &reconcile.cleared {
225 println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
226 }
227
228 let db_disabled = crate::plugins::disabled_ids(&db).await?;
229 let quarantined = crate::plugins::quarantined(&db).await?;
230
231 let mut registry = ModuleRegistry::new();
232 for module in builtin_modules() {
233 registry.register_boxed(module);
234 }
235 for module in self.app_modules {
236 registry.register_boxed(module);
237 }
238 for module in self.plugin_modules {
239 registry.register_boxed(module);
240 }
241 let capabilities =
242 laterite_core::capabilities::check(&db.pool, db.backend, ®istry).await?;
243
244 let ordered = registry.ordered()?;
248
249 let builtins: HashSet<String> = builtin_modules()
254 .iter()
255 .map(|m| m.id().to_string())
256 .collect();
257 let mut seed: HashMap<String, String> = HashMap::new();
258 for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
259 if builtins.contains(id) {
260 eprintln!("Ignoring request to disable built-in module '{id}'.");
261 } else {
262 seed.insert(id.clone(), "disabled".to_string());
263 }
264 }
265 for (id, reason) in &quarantined {
268 if !builtins.contains(id) {
269 seed.insert(id.clone(), format!("quarantined: {reason}"));
270 }
271 }
272 let skipped = skip_plan(&ordered, &seed);
273 report_plugins(&ordered, &skipped);
274 let active: Vec<&dyn Module> = ordered
275 .iter()
276 .copied()
277 .filter(|m| !skipped.contains_key(m.id().as_str()))
278 .collect();
279
280 let mut contributions = Registry::new();
285 let mut failed: HashMap<String, String> = HashMap::new();
286 for module in &active {
287 let mid = module.id();
288 let id = mid.as_str();
289 let eligible = plugin_ids.contains(id);
290
291 if let Some(dep) = module
292 .requires()
293 .iter()
294 .find(|r| failed.contains_key(r.as_str()))
295 {
296 let reason = format!("requires {}, which failed to load", dep.as_str());
297 println!("Plugin '{id}' off: {reason}");
298 failed.insert(id.to_string(), reason);
299 continue;
300 }
301
302 if eligible {
307 crate::plugins::mark_loading(&db, id, &fingerprint).await?;
308 }
309
310 match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
311 {
312 Ok(()) => {}
313 Err(e) if !eligible => return Err(e.into()),
314 Err(e) => {
315 let reason = format!("migration failed: {e}");
316 eprintln!("Plugin '{id}' quarantined: {reason}");
317 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
318 failed.insert(id.to_string(), reason);
319 continue;
320 }
321 }
322
323 contributions.set_owner(mid);
324 let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
325 match registered {
326 Ok(()) => {
329 if eligible {
330 crate::plugins::clear_loading(&db, id).await?;
331 }
332 }
333 Err(panic) => {
334 contributions.purge_owner(mid);
335 if !eligible {
336 resume_unwind(panic);
337 }
338 let reason = "register panicked".to_string();
339 eprintln!("Plugin '{id}' quarantined: {reason}");
340 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
341 failed.insert(id.to_string(), reason);
342 }
343 }
344 }
345 for module in &active {
352 let mid = module.id();
353 let id = mid.as_str();
354 if failed.contains_key(id) {
355 continue;
356 }
357 let cx = laterite_core::BootCx {
358 db: &db,
359 registry: &contributions,
360 };
361 match module.boot(&cx).await {
362 Ok(()) => {}
363 Err(e) if !plugin_ids.contains(id) => return Err(e.into()),
364 Err(e) => {
365 let reason = format!("boot failed: {e}");
366 eprintln!("Plugin '{id}' quarantined: {reason}");
367 crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
368 contributions.purge_owner(mid);
369 failed.insert(id.to_string(), reason);
370 }
371 }
372 }
373 let active: Vec<&dyn Module> = active
374 .into_iter()
375 .filter(|m| !failed.contains_key(m.id().as_str()))
376 .collect();
377 let bases: HashMap<String, Option<&'static str>> = active
378 .iter()
379 .map(|m| (m.id().to_string(), m.admin_base()))
380 .collect();
381 let mut resources = Vec::new();
382 for (owner, mut resource) in contributions.take_owned::<Resource>() {
383 let base = bases.get(owner.as_str()).copied().flatten();
384 crate::rebase_resource(
385 &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
386 &mut resource,
387 );
388 resources.push((owner, resource));
389 }
390 let mut screens = Vec::new();
391 for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
392 let base = bases.get(owner.as_str()).copied().flatten();
393 reg.base_path =
394 crate::routemap::resolve(owner, ®.base_path, base, &config.backend.paths);
395 screens.push((owner, reg));
396 }
397
398 let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
399
400 let mut claims: Vec<(String, String)> = crate::builtin_resources()
403 .iter()
404 .map(|r| (r.base_path.clone(), "the framework".to_string()))
405 .collect();
406 claims.extend(
407 resources
408 .iter()
409 .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
410 );
411 claims.extend(
412 screens
413 .iter()
414 .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
415 );
416 crate::routemap::check_segments(&claims);
417 crate::routemap::check_collisions(&claims);
418 let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
419 for resource in &resources {
421 if let Some(form) = &resource.form {
422 for field in form.fields.iter().filter(|f| f.field_type == "sensitive") {
423 laterite_core::crypt::register_column(
424 &db,
425 &form.entity,
426 &field.name,
427 laterite_core::crypt::FIELD_PURPOSE,
428 )
429 .await?;
430 }
431 }
432 }
433 let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
434 crate::routemap::check_public(
437 &public
438 .iter()
439 .map(|(owner, r)| (r.path.clone(), owner.to_string()))
440 .collect::<Vec<_>>(),
441 &crate::normalize_path(&config.backend.path),
442 );
443 let public: Vec<crate::routes::PublicRouteReg> =
444 public.into_iter().map(|(_, r)| r).collect();
445 let mut settings = Vec::new();
450 for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
451 let declared = item
454 .code
455 .strip_prefix(&format!("{}.", owner.as_str()))
456 .unwrap_or(&item.code)
457 .replace('.', "/");
458 let base = bases.get(owner.as_str()).copied().flatten();
459 item.mount_at(crate::routemap::resolve(
460 owner,
461 &format!("/{declared}"),
462 base,
463 &config.backend.paths,
464 ));
465 settings.push((owner, item));
466 }
467 let settings_claims: Vec<(String, String)> = settings
470 .iter()
471 .filter(|(_, i)| i.link.is_none())
472 .map(|(owner, i)| (i.route(), owner.to_string()))
473 .collect();
474 crate::routemap::check_segments(&settings_claims);
475 let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
476 let permissions = contributions.take::<Permission>();
477 let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
478 let persisters = contributions.take::<crate::persist::PersisterReg>();
479 let listeners = contributions.take::<laterite_core::ModelListenerReg>();
480 let field_types = contributions.take::<crate::field::FieldTypeReg>();
481 let column_types = contributions.take::<crate::list::ColumnTypeReg>();
482 let nav = contributions.take::<crate::nav::NavItem>();
483 let resource_hooks = contributions.take::<crate::ResourceHook>();
484 let widgets = contributions.take::<crate::widget::WidgetReg>();
485 let skins = contributions.take::<crate::skin::Skin>();
486 let assets = contributions.take::<crate::AssetReg>();
487 let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
489
490 let mut bus = laterite_core::Events::builder(db.clone());
493 for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
494 bus = bus.registered(owner, listener);
495 }
496 let events = bus.build()?;
497
498 let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
499 tokio::spawn(purge_hourly(auth.clone()));
503 let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
504 let secret = signing_key(&db, config.backend.secret.as_deref()).await?;
505 let crypt = crypt_from_config(&config, &secret)?;
506 let admin_config = AdminConfig {
507 secure_cookie: config.backend.secure_cookie,
508 trusted_proxies: config.backend.trusted_proxies.clone(),
509 timezone: config.backend.timezone.clone(),
510 locale: config.app.locale.clone(),
511 app_name: config.app.name.clone(),
512 path: config.backend.path.clone(),
513 origin: origin.clone(),
514 storage_dir: config.backend.storage.clone(),
515 skin: config.backend.skin.clone(),
516 upload_extensions: config.backend.upload_extensions.clone(),
517 order: config.backend.order.clone(),
518 menu: config.backend.menu.clone(),
519 secret,
520 crypt: Some(std::sync::Arc::new(crypt)),
521 };
522 let mut catalogs = laterite_core::CatalogStore::builder();
526 for module in active
527 .iter()
528 .filter(|m| !failed.contains_key(m.id().as_str()))
529 {
530 for (locale, po) in module.catalogs() {
531 catalogs = catalogs.po(locale, po).map_err(|e| {
532 laterite_core::CoreError::Config(format!(
533 "module {}: {locale} catalog: {e}",
534 module.id().as_str()
535 ))
536 })?;
537 }
538 }
539
540 let renamed =
543 laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
544 .await?;
545 if renamed > 0 {
546 tracing::info!(rows = renamed, "rewrote renamed permission codes");
547 }
548
549 laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
553
554 let mut app = router(
555 auth,
556 db.clone(),
557 crate::Contributions {
558 nav,
559 resource_hooks,
560 widgets,
561 skins,
562 assets,
563 resources,
564 settings,
565 permissions,
566 picker_sources,
567 persisters,
568 listeners,
569 field_types,
570 column_types,
571 icons,
572 screens,
573 public_routes: public,
574 plugin_defined: std::sync::Arc::new(contributions),
578 },
579 admin_config,
580 std::sync::Arc::new(catalogs.build()),
581 );
582
583 if let Some(extend) = self.extend {
584 let ctx = BootstrapCtx {
585 db,
586 capabilities,
587 translator: Translator::new(&config.app.locale),
588 events,
589 };
590 app = extend(app, &ctx);
591 }
592
593 let listener = bind(&config.server.listen).await?;
594 let admin_path = normalize_path(&config.backend.path);
595 println!("{} on {origin}{admin_path}", config.app.name);
596 axum::serve(
597 listener,
598 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
599 )
600 .await?;
601 Ok(())
602 }
603}
604
605async fn purge_expired(auth: &AuthService) {
609 match auth.purge_expired().await {
610 Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
611 sessions = purged.sessions,
612 remember_tokens = purged.remember_tokens,
613 "purged expired sessions and credentials"
614 ),
615 Ok(_) => {}
616 Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
617 }
618}
619
620async fn purge_hourly(auth: AuthService) {
622 let mut ticks = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
623 ticks.tick().await;
625 loop {
626 ticks.tick().await;
627 purge_expired(&auth).await;
628 }
629}
630
631async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
632 use listenfd::ListenFd;
633 match ListenFd::from_env().take_tcp_listener(0)? {
634 Some(std_listener) => {
635 std_listener.set_nonblocking(true)?;
636 Ok(tokio::net::TcpListener::from_std(std_listener)?)
637 }
638 None => Ok(tokio::net::TcpListener::bind(listen).await?),
639 }
640}
641
642fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
648 let mut skipped: HashMap<String, String> = HashMap::new();
649 for module in ordered {
650 let id = module.id().as_str();
651 if let Some(reason) = seed.get(id) {
652 skipped.insert(id.to_string(), reason.clone());
653 continue;
654 }
655 if let Some(dep) = module
656 .requires()
657 .iter()
658 .find(|r| skipped.contains_key(r.as_str()))
659 {
660 skipped.insert(
661 id.to_string(),
662 format!("requires {}, which is off", dep.as_str()),
663 );
664 }
665 }
666 skipped
667}
668
669fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
672 if skipped.is_empty() {
673 return;
674 }
675 for module in ordered {
676 if let Some(reason) = skipped.get(module.id().as_str()) {
677 println!("Plugin '{}' off: {reason}", module.id());
678 }
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use laterite_core::ModuleId;
686
687 #[test]
688 fn env_prefix_is_unset_until_overridden_in_code() {
689 assert_eq!(Bootstrap::new("config").env_prefix, None);
690 assert_eq!(
691 Bootstrap::new("config")
692 .env_prefix("ACME")
693 .env_prefix
694 .as_deref(),
695 Some("ACME")
696 );
697 }
698
699 #[test]
700 fn app_config_loads_named_sections_and_falls_back_to_defaults() {
701 let dir = tempfile::tempdir().unwrap();
702 std::fs::write(
703 dir.path().join("default.toml"),
704 "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
705 [database]\nurl = \"postgres://x\"\n",
706 )
707 .unwrap();
708 let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
709 assert_eq!(cfg.app.name, "Acme");
710 assert_eq!(cfg.server.listen, "0.0.0.0:9000");
711 assert_eq!(cfg.database.url, "postgres://x");
712 assert_eq!(cfg.backend.path, "/admin");
713 assert!(!cfg.backend.secure_cookie);
714 assert!(cfg.app.url.is_none());
715 }
716
717 struct M(&'static str, &'static [ModuleId]);
718 impl Module for M {
719 fn id(&self) -> ModuleId {
720 ModuleId::new(self.0)
721 }
722 fn requires(&self) -> &'static [ModuleId] {
723 self.1
724 }
725 }
726
727 #[test]
728 fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
729 const NONE: [ModuleId; 0] = [];
730 const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
731 const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
732 let (a, b, c, d) = (
735 M("acme.a", &NONE),
736 M("acme.b", &REQ_A),
737 M("acme.c", &REQ_B),
738 M("acme.d", &NONE),
739 );
740 let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
741
742 let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
743 .into_iter()
744 .collect();
745 let skipped = skip_plan(&ordered, &seed);
746
747 assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
749 assert!(skipped["acme.b"].contains("requires acme.a"));
750 assert!(skipped["acme.c"].contains("requires acme.b"));
751 assert!(!skipped.contains_key("acme.d"));
752 }
753}
754
755const SIGNING_KEY: &str = "laterite.signing_key";
757
758pub fn crypt_from_config(
764 config: &AppConfig,
765 legacy: &[u8],
766) -> anyhow::Result<laterite_core::Crypt> {
767 match config
768 .app
769 .key
770 .as_deref()
771 .map(str::trim)
772 .filter(|k| !k.is_empty())
773 {
774 Some(key) => laterite_core::Crypt::from_config(key, &config.app.previous_keys)
775 .map_err(|e| anyhow::anyhow!("app.key: {e}")),
776 None => {
777 if !config.app.debug {
778 tracing::warn!(
779 "app.key is not set; the key derives from backend.secret or the stored key. Run `lat key generate`."
780 );
781 }
782 Ok(laterite_core::Crypt::new(
783 laterite_core::MasterKey::derived(0, legacy),
784 Vec::new(),
785 ))
786 }
787 }
788}
789
790pub async fn legacy_key_material(
793 db: &laterite_core::Db,
794 configured: Option<&str>,
795) -> anyhow::Result<Vec<u8>> {
796 signing_key(db, configured).await
797}
798
799async fn signing_key(db: &laterite_core::Db, configured: Option<&str>) -> anyhow::Result<Vec<u8>> {
800 if let Some(secret) = configured.map(str::trim).filter(|s| !s.is_empty()) {
801 return Ok(secret.as_bytes().to_vec());
802 }
803 if let Some(serde_json::Value::String(hex)) =
804 crate::settings::store::get(db, SIGNING_KEY).await?
805 {
806 if let Some(key) = crate::routes::unhex(&hex).filter(|k| k.len() >= 16) {
807 return Ok(key);
808 }
809 }
810 let key = crate::random_key();
811 let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
812 crate::settings::store::set(db, SIGNING_KEY, &serde_json::Value::String(hex)).await?;
813 Ok(key)
814}