1use serde::{Deserialize, Serialize};
10
11use super::store::SettingsModel;
12use super::{FormField, SettingsItem};
13use laterite_core::Db;
14
15#[derive(Debug, Default, Serialize, Deserialize)]
18pub struct BrandSetting {
19 #[serde(default)]
22 pub app_name: String,
23 #[serde(default)]
25 pub logo: String,
26 #[serde(default)]
28 pub favicon: String,
29 #[serde(default)]
31 pub accent: String,
32 #[serde(default)]
34 pub mode: String,
35 #[serde(default)]
37 pub login_prompt: String,
38 #[serde(default)]
40 pub login_tagline: String,
41 #[serde(default)]
43 pub login_background: String,
44 #[serde(default = "yes")]
46 pub login_hero: bool,
47 #[serde(default)]
49 pub custom_css: String,
50}
51
52fn yes() -> bool {
53 true
54}
55
56impl SettingsModel for BrandSetting {
57 const CODE: &'static str = "laterite.brand";
58}
59
60pub(crate) fn settings_item() -> SettingsItem {
63 SettingsItem::new(
64 BrandSetting::CODE,
65 "Branding",
66 vec![
67 FormField::text("app_name", "Application name").help(
68 "Shown as the admin brand. Clearing it falls back to the configured application name.",
69 ),
70 FormField::of("logo", "Logo", "media")
71 .options(serde_json::json!({ "mode": "image" }))
72 .help("Shown in the top bar and on the sign-in page in place of the mark. Keep it on a public disk."),
73 FormField::of("favicon", "Browser icon", "media")
74 .options(serde_json::json!({ "mode": "image" }))
75 .help("The tab icon; a square PNG or SVG."),
76 FormField::of("accent", "Accent colour", "text")
77 .options(serde_json::json!({ "input": "color" }))
78 .help("Buttons, links and the active section take this colour."),
79 FormField::select(
80 "mode",
81 "Colour mode",
82 vec![
83 ("auto", "Follow the system"),
84 ("light", "Light"),
85 ("dark", "Dark"),
86 ],
87 )
88 .help("What an operator sees before choosing their own."),
89 FormField::text("login_prompt", "Sign-in prompt")
90 .help("The line above the sign-in form."),
91 FormField::text("login_tagline", "Sign-in tagline")
92 .help("The line on the panel beside the form."),
93 FormField::of("login_background", "Sign-in background", "media")
94 .options(serde_json::json!({ "mode": "image" }))
95 .help("An image behind the panel beside the form."),
96 FormField::switch("login_hero", "Show the panel beside the sign-in form"),
97 FormField::of("custom_css", "Custom stylesheet", "codeeditor")
98 .options(serde_json::json!({ "language": "css", "rows": 8 }))
99 .help("Rules added to every admin page. Imports and scripts are removed."),
100 ],
101 )
102 .description("The name, mark, colours and sign-in page of the admin.")
103 .category("System")
104 .slot(laterite_core::arrange::slot::SYSTEM)
105 .order(10)
106 .icon("palette")
107 .permission("backend.manage_branding")
108 .at("/settings/branding")
110}
111
112#[derive(Debug, Clone, Default)]
116pub struct Brand {
117 pub name: String,
118 pub logo_url: Option<String>,
119 pub favicon_url: Option<String>,
120 pub mode: String,
122 pub login_prompt: Option<String>,
123 pub login_tagline: Option<String>,
124 pub login_background_url: Option<String>,
125 pub login_hero: bool,
126 pub css: String,
129}
130
131impl Brand {
132 pub fn of_name(name: &str) -> Self {
134 Self {
135 name: name.to_string(),
136 mode: "auto".to_string(),
137 login_hero: true,
138 ..Self::default()
139 }
140 }
141
142 pub async fn resolve(db: &Db, app_name: &str) -> Self {
144 let setting = super::store::load::<BrandSetting>(db)
145 .await
146 .unwrap_or_default();
147 let name = if setting.app_name.trim().is_empty() {
148 app_name.to_string()
149 } else {
150 setting.app_name.trim().to_string()
151 };
152 let mode = match setting.mode.as_str() {
153 "light" | "dark" => setting.mode.clone(),
154 _ => "auto".to_string(),
155 };
156 let mut css = accent_css(&setting.accent).unwrap_or_default();
157 let custom = clean_css(&setting.custom_css);
158 if !custom.is_empty() {
159 css.push('\n');
160 css.push_str(&custom);
161 }
162 let some = |s: &str| {
163 let t = s.trim();
164 (!t.is_empty()).then(|| t.to_string())
165 };
166 Self {
167 name,
168 logo_url: media_url(db, &setting.logo).await,
169 favicon_url: media_url(db, &setting.favicon).await,
170 mode,
171 login_prompt: some(&setting.login_prompt),
172 login_tagline: some(&setting.login_tagline),
173 login_background_url: media_url(db, &setting.login_background).await,
174 login_hero: setting.login_hero,
175 css,
176 }
177 }
178}
179
180async fn media_url(db: &Db, id: &str) -> Option<String> {
183 let id: i64 = id.trim().parse().ok()?;
184 let record = laterite_media::record::find(db, id).await.ok().flatten()?;
185 Some(crate::medialib::file_url(&record))
186}
187
188pub(crate) fn accent_css(hex: &str) -> Option<String> {
191 let (r, g, b) = parse_hex(hex)?;
192 let shade = |k: f32| {
193 format!(
194 "#{:02X}{:02X}{:02X}",
195 (r as f32 * k) as u8,
196 (g as f32 * k) as u8,
197 (b as f32 * k) as u8
198 )
199 };
200 let tint = |a: f32| format!("rgba({r},{g},{b},{a})");
201 Some(format!(
202 ":root{{--accent:{hex};--accent-hover:{};--accent-active:{};--accent-subtle:{};--accent-quiet:{};--text-link:{hex};--text-link-hover:{}}}",
203 shade(0.88),
204 shade(0.76),
205 tint(0.14),
206 tint(0.09),
207 shade(0.88),
208 ))
209}
210
211fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
212 let h = hex.trim().strip_prefix('#')?;
213 if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
214 return None;
215 }
216 let v = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
217 Some((v(0)?, v(2)?, v(4)?))
218}
219
220pub(crate) fn clean_css(css: &str) -> String {
223 laterite_media::sanitize::css(css)
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn an_accent_sets_the_variables_and_rejects_anything_but_a_hex() {
232 let css = accent_css("#C44E2F").unwrap();
233 assert!(css.starts_with(":root{--accent:#C44E2F;"));
234 assert!(css.contains("--accent-subtle:rgba(196,78,47,0.14)"));
235 assert!(css.contains("--accent-hover:#AC4429"));
236 assert!(accent_css("red").is_none());
237 assert!(accent_css("#fff").is_none());
238 assert!(accent_css("").is_none());
239 }
240
241 #[test]
242 fn a_custom_stylesheet_loses_imports_scripts_and_closing_tags() {
243 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}";
244 assert_eq!(
245 clean_css(dirty),
246 ".lat-topnav{background:#123}\n.ok{color:red}"
247 );
248 }
249}