use std::collections::{HashMap, HashSet};
use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
use std::path::PathBuf;
use axum::Router;
use laterite_auth::{AuthConfig, AuthService};
use laterite_core::config::{self, AppMeta, BackendConfig, DatabaseConfig, ServerConfig};
use laterite_core::{CapabilitySet, Db, Module, ModuleRegistry, Registry, Translator};
use serde::Deserialize;
use crate::settings::SettingsItem;
use crate::{builtin_modules, normalize_path, router, AdminConfig, Permission, Resource};
pub const DEFAULT_ENV_PREFIX: &str = "LAT";
fn resolve_env_prefix(code: Option<String>, declared: Option<String>) -> String {
code.or(declared)
.unwrap_or_else(|| DEFAULT_ENV_PREFIX.to_string())
}
#[cfg(test)]
mod env_prefix_tests {
use super::resolve_env_prefix;
#[test]
fn code_override_then_declaration_then_default() {
assert_eq!(resolve_env_prefix(None, None), "LAT");
assert_eq!(resolve_env_prefix(None, Some("ACME".into())), "ACME");
assert_eq!(
resolve_env_prefix(Some("CODE".into()), Some("ACME".into())),
"CODE"
);
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
#[serde(default)]
pub app: AppMeta,
pub server: ServerConfig,
pub database: DatabaseConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub backend: BackendConfig,
#[serde(default)]
pub plugins: PluginsConfig,
#[serde(default)]
pub cache: laterite_core::config::CacheConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PluginsConfig {
#[serde(default)]
pub disabled: Vec<String>,
}
pub struct BootstrapCtx {
db: Db,
capabilities: CapabilitySet,
translator: Translator,
events: laterite_core::Events,
cache: laterite_core::Cache,
}
impl BootstrapCtx {
pub fn db(&self) -> Db {
self.db.clone()
}
pub fn capabilities(&self) -> &CapabilitySet {
&self.capabilities
}
pub fn translator(&self) -> &Translator {
&self.translator
}
pub fn events(&self) -> &laterite_core::Events {
&self.events
}
pub fn cache(&self) -> &laterite_core::Cache {
&self.cache
}
}
type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
pub struct Bootstrap {
config_dir: PathBuf,
env_prefix: Option<String>,
app_modules: Vec<Box<dyn Module>>,
plugin_modules: Vec<Box<dyn Module>>,
extend: Option<ExtendFn>,
}
impl Bootstrap {
pub fn new(config_dir: impl Into<PathBuf>) -> Self {
Self {
config_dir: config_dir.into(),
env_prefix: None,
app_modules: Vec::new(),
plugin_modules: Vec::new(),
extend: None,
}
}
pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
self.env_prefix = Some(prefix.into());
self
}
pub fn module(mut self, module: impl Module) -> Self {
self.app_modules.push(Box::new(module));
self
}
pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
self.plugin_modules.extend(modules);
self
}
pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
self.extend = Some(Box::new(f));
self
}
pub async fn serve(self) -> anyhow::Result<()> {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
)
.try_init();
let env_prefix = resolve_env_prefix(
self.env_prefix.clone(),
config::declared_env_prefix(&self.config_dir)?,
);
let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
laterite_core::config::set_debug(config.app.debug);
let db = laterite_core::db::connect(&config.database).await?;
let plugin_ids: HashSet<String> = self
.plugin_modules
.iter()
.map(|m| m.id().to_string())
.collect();
laterite_core::migration::run(
&db.pool,
db.backend,
&[crate::plugins::PluginsModule.migrations()],
)
.await?;
crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
let fingerprint = crate::plugins::binary_fingerprint();
let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
for id in &reconcile.crashed {
eprintln!(
"Plugin '{id}' quarantined: {}.",
crate::plugins::CRASH_REASON
);
}
for id in &reconcile.cleared {
println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
}
let db_disabled = crate::plugins::disabled_ids(&db).await?;
let quarantined = crate::plugins::quarantined(&db).await?;
let mut registry = ModuleRegistry::new();
for module in builtin_modules() {
registry.register_boxed(module);
}
for module in self.app_modules {
registry.register_boxed(module);
}
for module in self.plugin_modules {
registry.register_boxed(module);
}
let capabilities =
laterite_core::capabilities::check(&db.pool, db.backend, ®istry).await?;
let ordered = registry.ordered()?;
let builtins: HashSet<String> = builtin_modules()
.iter()
.map(|m| m.id().to_string())
.collect();
let mut seed: HashMap<String, String> = HashMap::new();
for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
if builtins.contains(id) {
eprintln!("Ignoring request to disable built-in module '{id}'.");
} else {
seed.insert(id.clone(), "disabled".to_string());
}
}
for (id, reason) in &quarantined {
if !builtins.contains(id) {
seed.insert(id.clone(), format!("quarantined: {reason}"));
}
}
let skipped = skip_plan(&ordered, &seed);
report_plugins(&ordered, &skipped);
let active: Vec<&dyn Module> = ordered
.iter()
.copied()
.filter(|m| !skipped.contains_key(m.id().as_str()))
.collect();
let mut contributions = Registry::new();
let mut failed: HashMap<String, String> = HashMap::new();
for module in &active {
let mid = module.id();
let id = mid.as_str();
let eligible = plugin_ids.contains(id);
if let Some(dep) = module
.requires()
.iter()
.find(|r| failed.contains_key(r.as_str()))
{
let reason = format!("requires {}, which failed to load", dep.as_str());
println!("Plugin '{id}' off: {reason}");
failed.insert(id.to_string(), reason);
continue;
}
if eligible {
crate::plugins::mark_loading(&db, id, &fingerprint).await?;
}
match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
{
Ok(()) => {}
Err(e) if !eligible => return Err(e.into()),
Err(e) => {
let reason = format!("migration failed: {e}");
eprintln!("Plugin '{id}' quarantined: {reason}");
crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
failed.insert(id.to_string(), reason);
continue;
}
}
contributions.set_owner(mid);
let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
match registered {
Ok(()) => {
if eligible {
crate::plugins::clear_loading(&db, id).await?;
}
}
Err(panic) => {
contributions.purge_owner(mid);
if !eligible {
resume_unwind(panic);
}
let reason = "register panicked".to_string();
eprintln!("Plugin '{id}' quarantined: {reason}");
crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
failed.insert(id.to_string(), reason);
}
}
}
for module in &active {
let mid = module.id();
let id = mid.as_str();
if failed.contains_key(id) {
continue;
}
let cx = laterite_core::BootCx {
db: &db,
registry: &contributions,
};
match module.boot(&cx).await {
Ok(()) => {}
Err(e) if !plugin_ids.contains(id) => return Err(e.into()),
Err(e) => {
let reason = format!("boot failed: {e}");
eprintln!("Plugin '{id}' quarantined: {reason}");
crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
contributions.purge_owner(mid);
failed.insert(id.to_string(), reason);
}
}
}
let active: Vec<&dyn Module> = active
.into_iter()
.filter(|m| !failed.contains_key(m.id().as_str()))
.collect();
let bases: HashMap<String, Option<&'static str>> = active
.iter()
.map(|m| (m.id().to_string(), m.admin_base()))
.collect();
let mut resources = Vec::new();
for (owner, mut resource) in contributions.take_owned::<Resource>() {
let base = bases.get(owner.as_str()).copied().flatten();
crate::rebase_resource(
&crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
&mut resource,
);
resources.push((owner, resource));
}
let mut screens = Vec::new();
for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
let base = bases.get(owner.as_str()).copied().flatten();
reg.base_path =
crate::routemap::resolve(owner, ®.base_path, base, &config.backend.paths);
screens.push((owner, reg));
}
let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
let mut claims: Vec<(String, String)> = crate::builtin_resources()
.iter()
.map(|r| (r.base_path.clone(), "the framework".to_string()))
.collect();
claims.extend(
resources
.iter()
.map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
);
claims.extend(
screens
.iter()
.map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
);
crate::routemap::check_segments(&claims);
crate::routemap::check_collisions(&claims);
let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
for resource in &resources {
if let Some(form) = &resource.form {
for field in form.fields.iter().filter(|f| f.field_type == "sensitive") {
laterite_core::crypt::register_column(
&db,
&form.entity,
&field.name,
laterite_core::crypt::FIELD_PURPOSE,
)
.await?;
}
}
}
let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
crate::routemap::check_public(
&public
.iter()
.map(|(owner, r)| (r.path.clone(), owner.to_string()))
.collect::<Vec<_>>(),
&crate::normalize_path(&config.backend.path),
);
let public: Vec<crate::routes::PublicRouteReg> =
public.into_iter().map(|(_, r)| r).collect();
let mut settings = Vec::new();
for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
let declared = item
.code
.strip_prefix(&format!("{}.", owner.as_str()))
.unwrap_or(&item.code)
.replace('.', "/");
let base = bases.get(owner.as_str()).copied().flatten();
item.mount_at(crate::routemap::resolve(
owner,
&format!("/{declared}"),
base,
&config.backend.paths,
));
settings.push((owner, item));
}
let settings_claims: Vec<(String, String)> = settings
.iter()
.filter(|(_, i)| i.link.is_none())
.map(|(owner, i)| (i.route(), owner.to_string()))
.collect();
crate::routemap::check_segments(&settings_claims);
let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
let cache = laterite_core::cache::from_config(&config.cache, &db, config.app.debug).await?;
for (owner, namespace) in contributions.take_owned::<laterite_core::cache::CacheNamespace>()
{
cache.allow(namespace.qualified(owner));
}
if config.cache.poll_seconds > 0 {
cache.start_polling(std::time::Duration::from_secs(config.cache.poll_seconds));
}
tracing::info!(store = cache.store(), "cache ready");
let permissions = contributions.take::<Permission>();
let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
let persisters = contributions.take::<crate::persist::PersisterReg>();
let listeners = contributions.take::<laterite_core::ModelListenerReg>();
let field_types = contributions.take::<crate::field::FieldTypeReg>();
let column_types = contributions.take::<crate::list::ColumnTypeReg>();
let nav = contributions.take::<crate::nav::NavItem>();
let resource_hooks = contributions.take::<crate::ResourceHook>();
let widgets = contributions.take::<crate::widget::WidgetReg>();
let skins = contributions.take::<crate::skin::Skin>();
let assets = contributions.take::<crate::AssetReg>();
let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
let mut bus = laterite_core::Events::builder(db.clone());
for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
bus = bus.registered(owner, listener);
}
let events = bus.build()?;
let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
tokio::spawn(purge_hourly(auth.clone()));
let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
let secret = signing_key(&db, config.backend.secret.as_deref()).await?;
let crypt = crypt_from_config(&config, &secret)?;
let admin_config = AdminConfig {
secure_cookie: config.backend.secure_cookie,
trusted_proxies: config.backend.trusted_proxies.clone(),
timezone: config.backend.timezone.clone(),
locale: config.app.locale.clone(),
app_name: config.app.name.clone(),
path: config.backend.path.clone(),
origin: origin.clone(),
storage_dir: config.backend.storage.clone(),
skin: config.backend.skin.clone(),
upload_extensions: config.backend.upload_extensions.clone(),
order: config.backend.order.clone(),
menu: config.backend.menu.clone(),
secret,
crypt: Some(std::sync::Arc::new(crypt)),
cache: Some(cache.clone()),
};
let mut catalogs = laterite_core::CatalogStore::builder();
for module in active
.iter()
.filter(|m| !failed.contains_key(m.id().as_str()))
{
for (locale, po) in module.catalogs() {
catalogs = catalogs.po(locale, po).map_err(|e| {
laterite_core::CoreError::Config(format!(
"module {}: {locale} catalog: {e}",
module.id().as_str()
))
})?;
}
}
let renamed =
laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
.await?;
if renamed > 0 {
tracing::info!(rows = renamed, "rewrote renamed permission codes");
}
laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
let mut app = router(
auth,
db.clone(),
crate::Contributions {
nav,
resource_hooks,
widgets,
skins,
assets,
resources,
settings,
permissions,
picker_sources,
persisters,
listeners,
field_types,
column_types,
icons,
screens,
public_routes: public,
plugin_defined: std::sync::Arc::new(contributions),
},
admin_config,
std::sync::Arc::new(catalogs.build()),
);
if let Some(extend) = self.extend {
let ctx = BootstrapCtx {
db,
capabilities,
translator: Translator::new(&config.app.locale),
events,
cache,
};
app = extend(app, &ctx);
}
let listener = bind(&config.server.listen).await?;
let admin_path = normalize_path(&config.backend.path);
println!("{} on {origin}{admin_path}", config.app.name);
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}
}
async fn purge_expired(auth: &AuthService) {
match auth.purge_expired().await {
Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
sessions = purged.sessions,
remember_tokens = purged.remember_tokens,
"purged expired sessions and credentials"
),
Ok(_) => {}
Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
}
}
async fn purge_hourly(auth: AuthService) {
let mut ticks = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
ticks.tick().await;
loop {
ticks.tick().await;
purge_expired(&auth).await;
}
}
async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
use listenfd::ListenFd;
match ListenFd::from_env().take_tcp_listener(0)? {
Some(std_listener) => {
std_listener.set_nonblocking(true)?;
Ok(tokio::net::TcpListener::from_std(std_listener)?)
}
None => Ok(tokio::net::TcpListener::bind(listen).await?),
}
}
fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
let mut skipped: HashMap<String, String> = HashMap::new();
for module in ordered {
let id = module.id().as_str();
if let Some(reason) = seed.get(id) {
skipped.insert(id.to_string(), reason.clone());
continue;
}
if let Some(dep) = module
.requires()
.iter()
.find(|r| skipped.contains_key(r.as_str()))
{
skipped.insert(
id.to_string(),
format!("requires {}, which is off", dep.as_str()),
);
}
}
skipped
}
fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
if skipped.is_empty() {
return;
}
for module in ordered {
if let Some(reason) = skipped.get(module.id().as_str()) {
println!("Plugin '{}' off: {reason}", module.id());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use laterite_core::ModuleId;
#[test]
fn env_prefix_is_unset_until_overridden_in_code() {
assert_eq!(Bootstrap::new("config").env_prefix, None);
assert_eq!(
Bootstrap::new("config")
.env_prefix("ACME")
.env_prefix
.as_deref(),
Some("ACME")
);
}
#[test]
fn app_config_loads_named_sections_and_falls_back_to_defaults() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("default.toml"),
"[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
[database]\nurl = \"postgres://x\"\n",
)
.unwrap();
let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
assert_eq!(cfg.app.name, "Acme");
assert_eq!(cfg.server.listen, "0.0.0.0:9000");
assert_eq!(cfg.database.url, "postgres://x");
assert_eq!(cfg.backend.path, "/admin");
assert!(!cfg.backend.secure_cookie);
assert!(cfg.app.url.is_none());
}
struct M(&'static str, &'static [ModuleId]);
impl Module for M {
fn id(&self) -> ModuleId {
ModuleId::new(self.0)
}
fn requires(&self) -> &'static [ModuleId] {
self.1
}
}
#[test]
fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
const NONE: [ModuleId; 0] = [];
const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
let (a, b, c, d) = (
M("acme.a", &NONE),
M("acme.b", &REQ_A),
M("acme.c", &REQ_B),
M("acme.d", &NONE),
);
let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
.into_iter()
.collect();
let skipped = skip_plan(&ordered, &seed);
assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
assert!(skipped["acme.b"].contains("requires acme.a"));
assert!(skipped["acme.c"].contains("requires acme.b"));
assert!(!skipped.contains_key("acme.d"));
}
}
const SIGNING_KEY: &str = "laterite.signing_key";
pub fn crypt_from_config(
config: &AppConfig,
legacy: &[u8],
) -> anyhow::Result<laterite_core::Crypt> {
match config
.app
.key
.as_deref()
.map(str::trim)
.filter(|k| !k.is_empty())
{
Some(key) => laterite_core::Crypt::from_config(key, &config.app.previous_keys)
.map_err(|e| anyhow::anyhow!("app.key: {e}")),
None => {
if !config.app.debug {
tracing::warn!(
"app.key is not set; the key derives from backend.secret or the stored key. Run `lat key generate`."
);
}
Ok(laterite_core::Crypt::new(
laterite_core::MasterKey::derived(0, legacy),
Vec::new(),
))
}
}
}
pub async fn legacy_key_material(
db: &laterite_core::Db,
configured: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
signing_key(db, configured).await
}
async fn signing_key(db: &laterite_core::Db, configured: Option<&str>) -> anyhow::Result<Vec<u8>> {
if let Some(secret) = configured.map(str::trim).filter(|s| !s.is_empty()) {
return Ok(secret.as_bytes().to_vec());
}
if let Some(serde_json::Value::String(hex)) =
crate::settings::store::get(db, SIGNING_KEY).await?
{
if let Some(key) = crate::routes::unhex(&hex).filter(|k| k.len() >= 16) {
return Ok(key);
}
}
let key = crate::random_key();
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
crate::settings::store::set(db, SIGNING_KEY, &serde_json::Value::String(hex)).await?;
Ok(key)
}