use serde::{Deserialize, Serialize};
use super::store::SettingsModel;
use super::{FormField, SettingsItem};
use laterite_core::Db;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BrandSetting {
#[serde(default)]
pub app_name: String,
#[serde(default)]
pub logo: String,
#[serde(default)]
pub favicon: String,
#[serde(default)]
pub accent: String,
#[serde(default)]
pub mode: String,
#[serde(default)]
pub login_prompt: String,
#[serde(default)]
pub login_tagline: String,
#[serde(default)]
pub login_background: String,
#[serde(default = "yes")]
pub login_hero: bool,
#[serde(default)]
pub custom_css: String,
}
fn yes() -> bool {
true
}
impl SettingsModel for BrandSetting {
const CODE: &'static str = "laterite.brand";
}
pub(crate) fn settings_item() -> SettingsItem {
SettingsItem::new(
BrandSetting::CODE,
"Branding",
vec![
FormField::text("app_name", "Application name").help(
"Shown as the admin brand. Clearing it falls back to the configured application name.",
),
FormField::of("logo", "Logo", "media")
.options(serde_json::json!({ "mode": "image" }))
.help("Shown in the top bar and on the sign-in page in place of the mark. Keep it on a public disk."),
FormField::of("favicon", "Browser icon", "media")
.options(serde_json::json!({ "mode": "image" }))
.help("The tab icon; a square PNG or SVG."),
FormField::of("accent", "Accent colour", "text")
.options(serde_json::json!({ "input": "color" }))
.help("Buttons, links and the active section take this colour."),
FormField::select(
"mode",
"Colour mode",
vec![
("auto", "Follow the system"),
("light", "Light"),
("dark", "Dark"),
],
)
.help("What an operator sees before choosing their own."),
FormField::text("login_prompt", "Sign-in prompt")
.help("The line above the sign-in form."),
FormField::text("login_tagline", "Sign-in tagline")
.help("The line on the panel beside the form."),
FormField::of("login_background", "Sign-in background", "media")
.options(serde_json::json!({ "mode": "image" }))
.help("An image behind the panel beside the form."),
FormField::switch("login_hero", "Show the panel beside the sign-in form"),
FormField::of("custom_css", "Custom stylesheet", "codeeditor")
.options(serde_json::json!({ "language": "css", "rows": 8 }))
.help("Rules added to every admin page. Imports and scripts are removed."),
],
)
.description("The name, mark, colours and sign-in page of the admin.")
.category("System")
.slot(laterite_core::arrange::slot::SYSTEM)
.order(10)
.icon("palette")
.permission("backend.manage_branding")
.at("/settings/branding")
}
#[derive(Debug, Clone, Default)]
pub struct Brand {
pub name: String,
pub logo_url: Option<String>,
pub favicon_url: Option<String>,
pub mode: String,
pub login_prompt: Option<String>,
pub login_tagline: Option<String>,
pub login_background_url: Option<String>,
pub login_hero: bool,
pub css: String,
}
impl Brand {
pub fn of_name(name: &str) -> Self {
Self {
name: name.to_string(),
mode: "auto".to_string(),
login_hero: true,
..Self::default()
}
}
pub async fn resolve(db: &Db, app_name: &str) -> Self {
let setting = super::store::load::<BrandSetting>(db)
.await
.unwrap_or_default();
let name = if setting.app_name.trim().is_empty() {
app_name.to_string()
} else {
setting.app_name.trim().to_string()
};
let mode = match setting.mode.as_str() {
"light" | "dark" => setting.mode.clone(),
_ => "auto".to_string(),
};
let mut css = accent_css(&setting.accent).unwrap_or_default();
let custom = clean_css(&setting.custom_css);
if !custom.is_empty() {
css.push('\n');
css.push_str(&custom);
}
let some = |s: &str| {
let t = s.trim();
(!t.is_empty()).then(|| t.to_string())
};
Self {
name,
logo_url: media_url(db, &setting.logo).await,
favicon_url: media_url(db, &setting.favicon).await,
mode,
login_prompt: some(&setting.login_prompt),
login_tagline: some(&setting.login_tagline),
login_background_url: media_url(db, &setting.login_background).await,
login_hero: setting.login_hero,
css,
}
}
}
async fn media_url(db: &Db, id: &str) -> Option<String> {
let id: i64 = id.trim().parse().ok()?;
let record = laterite_media::record::find(db, id).await.ok().flatten()?;
Some(crate::medialib::file_url(&record))
}
pub(crate) fn accent_css(hex: &str) -> Option<String> {
let (r, g, b) = parse_hex(hex)?;
let shade = |k: f32| {
format!(
"#{:02X}{:02X}{:02X}",
(r as f32 * k) as u8,
(g as f32 * k) as u8,
(b as f32 * k) as u8
)
};
let tint = |a: f32| format!("rgba({r},{g},{b},{a})");
Some(format!(
":root{{--accent:{hex};--accent-hover:{};--accent-active:{};--accent-subtle:{};--accent-quiet:{};--text-link:{hex};--text-link-hover:{}}}",
shade(0.88),
shade(0.76),
tint(0.14),
tint(0.09),
shade(0.88),
))
}
fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
let h = hex.trim().strip_prefix('#')?;
if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let v = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
Some((v(0)?, v(2)?, v(4)?))
}
pub(crate) fn clean_css(css: &str) -> String {
laterite_media::sanitize::css(css)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_accent_sets_the_variables_and_rejects_anything_but_a_hex() {
let css = accent_css("#C44E2F").unwrap();
assert!(css.starts_with(":root{--accent:#C44E2F;"));
assert!(css.contains("--accent-subtle:rgba(196,78,47,0.14)"));
assert!(css.contains("--accent-hover:#AC4429"));
assert!(accent_css("red").is_none());
assert!(accent_css("#fff").is_none());
assert!(accent_css("").is_none());
}
#[test]
fn a_custom_stylesheet_loses_imports_scripts_and_closing_tags() {
let dirty = ".lat-topnav{background:#123}\n@import url(evil.css);\na{behavior:url(x.htc)}\n</style><script>1</script>\n.x{background:url(javascript:alert(1))}\n.ok{color:red}";
assert_eq!(
clean_css(dirty),
".lat-topnav{background:#123}\n.ok{color:red}"
);
}
}