use rustlavel::prelude::*;
use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Text,
LongText,
Number,
Toggle,
Choice,
Colour,
Secret,
}
pub struct Setting {
pub key: &'static str,
pub kind: Kind,
pub default: &'static str,
pub env: Option<&'static str>,
pub choices: &'static [(&'static str, &'static str)],
}
const fn s(key: &'static str, kind: Kind, default: &'static str) -> Setting {
Setting { key, kind, default, env: None, choices: &[] }
}
const fn env(key: &'static str, kind: Kind, default: &'static str, variable: &'static str) -> Setting {
Setting { key, kind, default, env: Some(variable), choices: &[] }
}
const fn choice(
key: &'static str,
default: &'static str,
choices: &'static [(&'static str, &'static str)],
) -> Setting {
Setting { key, kind: Kind::Choice, default, env: None, choices }
}
const DATE_FORMATS: &[(&str, &str)] = &[
("d/m/Y", "DD/MM/YYYY"),
("m/d/Y", "MM/DD/YYYY"),
("Y-m-d", "YYYY-MM-DD"),
("d M Y", "DD Mon YYYY"),
];
const TIME_FORMATS: &[(&str, &str)] = &[("24", "24 Hour"), ("12", "12 Hour")];
const TIMEZONES: &[(&str, &str)] = &[
("UTC", "UTC"),
("Asia/Jakarta", "Asia/Jakarta (WIB)"),
("Asia/Makassar", "Asia/Makassar (WITA)"),
("Asia/Jayapura", "Asia/Jayapura (WIT)"),
("Asia/Singapore", "Asia/Singapore"),
("Europe/London", "Europe/London"),
("America/New_York", "America/New_York"),
];
const MAIL_DRIVERS: &[(&str, &str)] =
&[("smtp", "SMTP"), ("log", "Log (write to the log)"), ("file", "File (write .eml files)")];
const MAIL_ENCRYPTION: &[(&str, &str)] =
&[("tls", "TLS"), ("starttls", "STARTTLS"), ("none", "None")];
const LENGTHS: &[(&str, &str)] =
&[("8", "8"), ("10", "10"), ("12", "12"), ("14", "14"), ("16", "16"), ("20", "20")];
const REUSE: &[(&str, &str)] = &[
("0", "Disabled"),
("3", "Last 3 passwords"),
("5", "Last 5 passwords"),
("10", "Last 10 passwords"),
];
const TIMEOUTS: &[(&str, &str)] = &[
("30", "30 minutes"),
("60", "1 hour"),
("120", "2 hours"),
("480", "8 hours"),
("1440", "24 hours"),
];
const SCHEDULES: &[(&str, &str)] = &[
("disabled", "Disabled — take them by hand"),
("6h", "Every 6 hours"),
("daily", "Daily"),
("weekly", "Weekly (Sunday)"),
];
const RETENTIONS: &[(&str, &str)] = &[
("0", "Keep everything"),
("7", "Keep the last 7"),
("14", "Keep the last 14"),
("30", "Keep the last 30"),
];
const DESTINATIONS: &[(&str, &str)] =
&[("local", "Local disk"), ("s3", "S3-compatible object store")];
const NUMBERS: &[(&str, &str)] = &[
("id", "1.234.567,89 — dot for thousands"),
("en", "1,234,567.89 — comma for thousands"),
("plain", "1234567.89 — no separator"),
];
const CURRENCIES: &[(&str, &str)] =
&[("Rp ", "Rp 1.234.567"), ("IDR ", "IDR 1.234.567"), ("$", "$1,234,567"), ("", "1.234.567")];
const WEEK_START: &[(&str, &str)] = &[("1", "Monday"), ("0", "Sunday")];
const ATTEMPTS: &[(&str, &str)] =
&[("3", "3"), ("5", "5"), ("10", "10"), ("0", "No limit (not advised)")];
const LOCKOUTS: &[(&str, &str)] = &[
("5", "5 minutes"),
("15", "15 minutes"),
("60", "1 hour"),
("1440", "24 hours"),
];
const LOCALES: &[(&str, &str)] =
&[("en", "English"), ("id", "Bahasa Indonesia"), ("ms", "Bahasa Melayu")];
pub const CATALOGUE: &[Setting] = &[
env("app.name", Kind::Text, "Rustlavel", "APP_NAME"),
env("app.url", Kind::Text, "http://localhost:8000", "APP_URL"),
s("app.description", Kind::LongText, ""),
choice("app.date_format", "d M Y", DATE_FORMATS),
choice("app.time_format", "24", TIME_FORMATS),
choice("app.timezone", "UTC", TIMEZONES),
Setting { key: "mail.driver", kind: Kind::Choice, default: "log", env: Some("MAIL_TRANSPORT"), choices: MAIL_DRIVERS },
env("mail.host", Kind::Text, "127.0.0.1", "MAIL_HOST"),
env("mail.port", Kind::Number, "1025", "MAIL_PORT"),
Setting { key: "mail.encryption", kind: Kind::Choice, default: "none", env: Some("MAIL_ENCRYPTION"), choices: MAIL_ENCRYPTION },
env("mail.username", Kind::Text, "", "MAIL_USERNAME"),
env("mail.password", Kind::Secret, "", "MAIL_PASSWORD"),
env("mail.from.address", Kind::Text, "noreply@example.com", "MAIL_FROM_ADDRESS"),
env("mail.from.name", Kind::Text, "Rustlavel", "MAIL_FROM_NAME"),
env("auth.registration.open", Kind::Toggle, "true", "AUTH_REGISTRATION_OPEN"),
s("auth.magic_link", Kind::Toggle, "false"),
s("auth.verify_email", Kind::Toggle, "true"),
s("auth.require_mfa", Kind::Toggle, "false"),
Setting { key: "auth.password.min_length", kind: Kind::Choice, default: "12", env: Some("AUTH_PASSWORD_MIN_LENGTH"), choices: LENGTHS },
s("auth.password.uppercase", Kind::Toggle, "false"),
s("auth.password.lowercase", Kind::Toggle, "false"),
s("auth.password.number", Kind::Toggle, "false"),
s("auth.password.symbol", Kind::Toggle, "false"),
s("auth.password.breached", Kind::Toggle, "false"),
choice("auth.password.reuse", "0", REUSE),
choice("auth.session.timeout", "120", TIMEOUTS),
choice("auth.lockout.attempts", "5", ATTEMPTS),
choice("auth.lockout.minutes", "15", LOCKOUTS),
choice("backup.schedule", "disabled", SCHEDULES),
choice("backup.retention", "0", RETENTIONS),
choice("backup.destination", "local", DESTINATIONS),
env("backup.path", Kind::Text, "storage/backups", "BACKUP_PATH"),
env("backup.bucket", Kind::Text, "", "BACKUP_BUCKET"),
choice("app.locale", "en", LOCALES),
s("app.locale.fallback", Kind::Text, "en"),
choice("app.number_format", "id", NUMBERS),
choice("app.currency", "Rp ", CURRENCIES),
choice("app.week_start", "1", WEEK_START),
s("theme.brand", Kind::Colour, "#2563eb"),
s("theme.login.light.from", Kind::Colour, "#3b82f6"),
s("theme.login.light.to", Kind::Colour, "#2563eb"),
s("theme.login.dark.from", Kind::Colour, "#1e3a5f"),
s("theme.login.dark.to", Kind::Colour, "#111827"),
s("theme.sidebar.light.bg", Kind::Colour, "#ffffff"),
s("theme.sidebar.light.text", Kind::Colour, "#374151"),
s("theme.sidebar.light.active_bg", Kind::Colour, "#eff6ff"),
s("theme.sidebar.light.active_text", Kind::Colour, "#2563eb"),
s("theme.sidebar.dark.bg", Kind::Colour, "#1f2937"),
s("theme.sidebar.dark.text", Kind::Colour, "#9ca3af"),
s("theme.sidebar.dark.active_bg", Kind::Colour, "#374151"),
s("theme.sidebar.dark.active_text", Kind::Colour, "#60a5fa"),
s("theme.logo.light", Kind::Text, ""),
s("theme.logo.dark", Kind::Text, ""),
];
pub fn declared(key: &str) -> Option<&'static Setting> {
CATALOGUE.iter().find(|setting| setting.key == key)
}
#[derive(Clone)]
pub struct Settings {
db: Database,
cache: Arc<RwLock<Option<BTreeMap<String, String>>>>,
key: Arc<rustlavel::auth::Encrypter>,
}
impl Settings {
pub fn new(db: Database, encrypter: rustlavel::auth::Encrypter) -> Self {
Settings { db, cache: Arc::new(RwLock::new(None)), key: Arc::new(encrypter) }
}
pub fn from_config(db: Database, config: &Config) -> Result<Self> {
Ok(Settings::new(db, rustlavel::auth::Encrypter::from_config(config)?))
}
async fn load(&self) -> Result<BTreeMap<String, String>> {
let rows = self.db.table("settings").get(&self.db).await?;
let mut values = BTreeMap::new();
for row in &rows {
let Ok(key) = row.get::<String>("key") else { continue };
let raw = row.get::<String>("value").unwrap_or_default();
let secret = row.get::<i64>("is_secret").map(|n| n != 0).unwrap_or(false);
let value = if secret && !raw.is_empty() {
match self.key.decrypt(&raw) {
Ok(plain) => plain,
Err(_) => {
warn!("the stored value for `{key}` could not be decrypted; treating it as unset");
continue;
}
}
} else {
raw
};
values.insert(key, value);
}
Ok(values)
}
async fn all(&self) -> Result<BTreeMap<String, String>> {
if let Some(cached) = self.cache.read().expect("settings lock").clone() {
return Ok(cached);
}
let loaded = self.load().await?;
*self.cache.write().expect("settings lock") = Some(loaded.clone());
Ok(loaded)
}
pub fn forget(&self) {
*self.cache.write().expect("settings lock") = None;
}
pub async fn get(&self, key: &str) -> String {
let declared = declared(key);
if let Some(variable) = declared.and_then(|setting| setting.env) {
let from_env = std::env::var(variable).unwrap_or_default();
if !from_env.is_empty() {
return from_env;
}
}
let stored = self.all().await.ok().and_then(|values| values.get(key).cloned());
match stored.filter(|value| !value.is_empty()) {
Some(value) => value,
None => declared.map(|setting| setting.default.to_string()).unwrap_or_default(),
}
}
pub async fn bool(&self, key: &str) -> bool {
matches!(self.get(key).await.as_str(), "1" | "true" | "yes" | "on")
}
pub async fn int(&self, key: &str, fallback: i64) -> i64 {
self.get(key).await.parse().unwrap_or(fallback)
}
pub fn overridden(key: &str) -> bool {
declared(key)
.and_then(|setting| setting.env)
.is_some_and(|variable| !std::env::var(variable).unwrap_or_default().is_empty())
}
pub async fn put(&self, key: &str, value: &str) -> Result<()> {
let Some(setting) = declared(key) else {
return Err(Error::msg(format!(
"`{key}` is not a setting. Add it to CATALOGUE in src/support/settings.rs first — \
a form that can write any key is a form that can write anything."
)));
};
let secret = setting.kind == Kind::Secret;
if secret && value.is_empty() {
return Ok(());
}
let stored = if secret { self.key.encrypt(value)? } else { value.to_string() };
let now = crate::support::tokens::now();
let existing = self.db.table("settings").filter("key", key).first(&self.db).await?;
match existing {
Some(_) => {
self.db
.table("settings")
.filter("key", key)
.update(&self.db, &[("value", stored.into()), ("updated_at", now.into())])
.await?;
}
None => {
self.db
.table("settings")
.insert_without_id(
&self.db,
&[
("key", key.into()),
("value", stored.into()),
("is_secret", secret.into()),
("created_at", now.clone().into()),
("updated_at", now.into()),
],
)
.await?;
}
}
self.forget();
Ok(())
}
pub async fn put_all(&self, values: &[(String, String)]) -> Result<usize> {
let mut written = 0;
for (key, value) in values {
if declared(key).is_some() {
self.put(key, value).await?;
written += 1;
}
}
Ok(written)
}
pub async fn view(&self, prefix: &str) -> Result<Json> {
let mut fields = Vec::new();
for setting in CATALOGUE.iter().filter(|s| s.key.starts_with(prefix)) {
let value = self.get(setting.key).await;
let choices: Vec<Json> = setting
.choices
.iter()
.map(|(value_, label)| {
Json::object([
("value", Json::from(*value_)),
("label", Json::from(*label)),
("selected", Json::from(*value_ == value)),
])
})
.collect();
fields.push((
setting.key.replace('.', "_"),
Json::object([
("key", Json::from(setting.key)),
("value", Json::from(value.as_str())),
("on", Json::from(matches!(value.as_str(), "1" | "true" | "yes" | "on"))),
("locked", Json::from(Settings::overridden(setting.key))),
("env", setting.env.map_or(Json::Null, Json::from)),
("choices", Json::Array(choices)),
]),
));
}
Ok(Json::object(fields))
}
pub async fn export(&self) -> Result<Json> {
let mut fields = Vec::new();
for setting in CATALOGUE {
let value = if setting.kind == Kind::Secret {
Json::from("(not exported)")
} else {
Json::from(self.get(setting.key).await)
};
fields.push((setting.key, value));
}
Ok(Json::object(fields))
}
}