1use std::sync::Arc;
2
3use allowthem_core::AllowThem;
4use allowthem_core::applications::BrandingConfig;
5use allowthem_core::types::{AccentInk, ClientId};
6use axum::Extension;
7
8pub const DEFAULT_ACCENT_HEX: &str = "#ffffff";
10
11pub fn derive_ink(hex: &str) -> AccentInk {
19 match parse_hex(hex) {
20 Some((r, g, b)) => {
21 let y = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000;
22 if y >= 160 {
23 AccentInk::Black
24 } else {
25 AccentInk::White
26 }
27 }
28 None => AccentInk::White,
29 }
30}
31
32pub fn resolve_accent(
45 branding: Option<&BrandingConfig>,
46) -> (String, &'static str, String, &'static str) {
47 let branded = branding.and_then(|b| b.accent_hex.as_deref().or(b.primary_color.as_deref()));
48 match branded {
49 Some(hex) => {
50 let accent = hex.to_string();
51 let ink = branding
52 .and_then(|b| b.accent_ink)
53 .unwrap_or_else(|| derive_ink(&accent));
54 let accent_light = accent.clone();
55 let ink_light = branding
56 .and_then(|b| b.accent_ink)
57 .unwrap_or_else(|| derive_ink(&accent_light));
58 (accent, ink.as_hex(), accent_light, ink_light.as_hex())
59 }
60 None => (
61 DEFAULT_ACCENT_HEX.to_string(),
62 "#000000",
63 "#000000".to_string(),
64 "#ffffff",
65 ),
66 }
67}
68
69pub async fn lookup_branding(
74 ath: &AllowThem,
75 client_id: Option<&ClientId>,
76) -> Option<BrandingConfig> {
77 let cid = client_id?;
78 match ath.db().get_branding_by_client_id(cid).await {
79 Ok(branding) => branding,
80 Err(e) => {
81 tracing::warn!(client_id = %cid, error = %e, "branding lookup failed");
82 None
83 }
84 }
85}
86
87#[derive(Debug, Clone)]
94pub struct DefaultBranding(pub BrandingConfig);
95
96pub async fn resolve_branding(
99 ath: &AllowThem,
100 client_id: Option<&ClientId>,
101 default: Option<&BrandingConfig>,
102) -> Option<BrandingConfig> {
103 if let Some(b) = lookup_branding(ath, client_id).await {
104 return Some(b);
105 }
106 default.cloned()
107}
108
109pub struct BrandingCtx<'a> {
117 pub app_name: &'a str,
118 pub accent: String,
119 pub accent_ink: &'static str,
120 pub accent_light: String,
121 pub accent_ink_light: &'static str,
122 pub logo_url: Option<&'a str>,
123}
124
125impl<'a> BrandingCtx<'a> {
126 pub fn from_branding(branding: Option<&'a BrandingConfig>) -> Self {
127 let (accent, accent_ink, accent_light, accent_ink_light) = resolve_accent(branding);
128 Self {
129 app_name: branding
130 .map(|b| b.application_name.as_str())
131 .unwrap_or("allowthem"),
132 accent,
133 accent_ink,
134 accent_light,
135 accent_ink_light,
136 logo_url: branding.and_then(|b| b.logo_url.as_deref()),
137 }
138 }
139}
140
141pub fn default_branding_ref(
149 ext: &Option<Extension<Arc<DefaultBranding>>>,
150) -> Option<&BrandingConfig> {
151 ext.as_ref().map(|Extension(d)| &d.0)
152}
153
154pub fn branding_context(branding: Option<&BrandingConfig>) -> minijinja::Value {
161 let ctx = BrandingCtx::from_branding(branding);
162 minijinja::context! {
163 branding,
164 app_name => ctx.app_name,
165 logo_url => ctx.logo_url,
166 accent => ctx.accent,
167 accent_ink => ctx.accent_ink,
168 accent_light => ctx.accent_light,
169 accent_ink_light => ctx.accent_ink_light,
170 }
171}
172
173fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
174 let bytes = hex.as_bytes();
175 if bytes.len() != 7 || bytes[0] != b'#' {
176 return None;
177 }
178 let r = u8::from_str_radix(&hex[1..3], 16).ok()?;
179 let g = u8::from_str_radix(&hex[3..5], 16).ok()?;
180 let b = u8::from_str_radix(&hex[5..7], 16).ok()?;
181 Some((r, g, b))
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use allowthem_core::applications::BrandingConfig;
188 use allowthem_core::types::AccentInk;
189 use allowthem_core::{AllowThem, AllowThemBuilder};
190
191 async fn test_ath() -> AllowThem {
192 AllowThemBuilder::new("sqlite::memory:")
193 .cookie_secure(false)
194 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
195 .build()
196 .await
197 .unwrap()
198 }
199
200 #[tokio::test]
201 async fn resolve_branding_returns_default_when_client_id_is_none() {
202 let ath = test_ath().await;
203 let default = BrandingConfig::new("Fallback Co");
204 let result = resolve_branding(&ath, None, Some(&default)).await;
205 assert_eq!(
206 result.as_ref().map(|b| b.application_name.as_str()),
207 Some("Fallback Co")
208 );
209 }
210
211 #[tokio::test]
212 async fn resolve_branding_returns_none_when_no_client_and_no_default() {
213 let ath = test_ath().await;
214 let result = resolve_branding(&ath, None, None).await;
215 assert!(result.is_none());
216 }
217
218 #[tokio::test]
219 async fn resolve_branding_returns_default_when_client_id_does_not_match() {
220 let ath = test_ath().await;
221 let default = BrandingConfig::new("Fallback Co");
222 let unknown: allowthem_core::types::ClientId =
223 serde_json::from_str("\"ath_does_not_exist\"").unwrap();
224 let result = resolve_branding(&ath, Some(&unknown), Some(&default)).await;
225 assert_eq!(
226 result.as_ref().map(|b| b.application_name.as_str()),
227 Some("Fallback Co")
228 );
229 }
230
231 #[test]
232 fn derive_ink_pastels_pair_with_black() {
233 assert_eq!(derive_ink("#cba6f7"), AccentInk::Black);
235 assert_eq!(derive_ink("#fab387"), AccentInk::Black);
236 assert_eq!(derive_ink("#a6e3a1"), AccentInk::Black);
237 }
238
239 #[test]
240 fn derive_ink_saturated_darks_pair_with_white() {
241 assert_eq!(derive_ink("#5b21b6"), AccentInk::White);
243 assert_eq!(derive_ink("#1e1b4b"), AccentInk::White);
244 assert_eq!(derive_ink("#000000"), AccentInk::White);
245 }
246
247 #[test]
248 fn derive_ink_pure_white_pairs_with_black() {
249 assert_eq!(derive_ink("#ffffff"), AccentInk::Black);
250 }
251
252 #[test]
253 fn derive_ink_invalid_hex_defaults_to_white() {
254 assert_eq!(derive_ink("not-a-color"), AccentInk::White);
257 assert_eq!(derive_ink("#zz"), AccentInk::White);
258 }
259
260 #[test]
261 fn resolve_accent_defaults_without_branding() {
262 let (accent, ink, accent_light, ink_light) = resolve_accent(None);
263 assert_eq!(accent, "#ffffff");
264 assert_eq!(ink, "#000000");
265 assert_eq!(accent_light, "#000000");
266 assert_eq!(ink_light, "#ffffff");
267 }
268
269 #[test]
270 fn resolve_accent_branded_quad_pins_color_in_both_modes() {
271 let b = BrandingConfig {
272 application_name: "test".into(),
273 logo_url: None,
274 primary_color: None,
275 accent_hex: Some("#ff6600".into()),
276 accent_ink: None,
277 forced_mode: None,
278 font_css_url: None,
279 font_family: None,
280 splash_text: None,
281 splash_image_url: None,
282 splash_primitive: None,
283 splash_url: None,
284 shader_cell_scale: None,
285 };
286 let (accent, ink, accent_light, ink_light) = resolve_accent(Some(&b));
287 assert_eq!(accent, "#ff6600");
289 assert_eq!(accent_light, "#ff6600");
290 assert_eq!(ink, ink_light);
292 }
293
294 #[test]
295 fn resolve_accent_uses_accent_hex_over_primary_color() {
296 let b = BrandingConfig {
297 application_name: "test".into(),
298 logo_url: None,
299 primary_color: Some("#ff0000".into()),
300 accent_hex: Some("#00ff00".into()),
301 accent_ink: None,
302 forced_mode: None,
303 font_css_url: None,
304 font_family: None,
305 splash_text: None,
306 splash_image_url: None,
307 splash_primitive: None,
308 splash_url: None,
309 shader_cell_scale: None,
310 };
311 let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
312 assert_eq!(accent, "#00ff00");
313 }
314
315 #[test]
316 fn resolve_accent_falls_back_to_primary_color() {
317 let b = BrandingConfig {
318 application_name: "test".into(),
319 logo_url: None,
320 primary_color: Some("#ff0000".into()),
321 accent_hex: None,
322 accent_ink: None,
323 forced_mode: None,
324 font_css_url: None,
325 font_family: None,
326 splash_text: None,
327 splash_image_url: None,
328 splash_primitive: None,
329 splash_url: None,
330 shader_cell_scale: None,
331 };
332 let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
333 assert_eq!(accent, "#ff0000");
334 }
335
336 #[test]
337 fn resolve_accent_honors_explicit_ink() {
338 let b = BrandingConfig {
339 application_name: "test".into(),
340 logo_url: None,
341 primary_color: None,
342 accent_hex: Some("#ffffff".into()), accent_ink: Some(AccentInk::White), forced_mode: None,
345 font_css_url: None,
346 font_family: None,
347 splash_text: None,
348 splash_image_url: None,
349 splash_primitive: None,
350 splash_url: None,
351 shader_cell_scale: None,
352 };
353 let (_accent, ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
354 assert_eq!(ink, "#ffffff");
355 }
356
357 #[test]
358 fn branding_ctx_none_gives_allowthem_defaults() {
359 let ctx = BrandingCtx::from_branding(None);
360 assert_eq!(ctx.app_name, "allowthem");
361 assert_eq!(ctx.accent, "#ffffff");
362 assert_eq!(ctx.accent_ink, "#000000");
363 assert_eq!(ctx.accent_light, "#000000");
364 assert_eq!(ctx.accent_ink_light, "#ffffff");
365 assert!(ctx.logo_url.is_none());
366 }
367
368 #[test]
369 fn branding_ctx_some_projects_fields() {
370 let b = BrandingConfig::new("Fixture Co")
371 .with_accent("#ff00aa", AccentInk::Black)
372 .with_logo_url("https://cdn.example/logo.svg");
373 let ctx = BrandingCtx::from_branding(Some(&b));
374 assert_eq!(ctx.app_name, "Fixture Co");
375 assert_eq!(ctx.accent, "#ff00aa");
376 assert_eq!(ctx.accent_ink, "#000000"); assert_eq!(ctx.logo_url, Some("https://cdn.example/logo.svg"));
378 }
379
380 #[test]
381 fn default_branding_ref_none_passes_through() {
382 let ext: Option<Extension<Arc<DefaultBranding>>> = None;
383 assert!(default_branding_ref(&ext).is_none());
384 }
385
386 #[test]
387 fn default_branding_ref_some_unwraps_to_inner_branding() {
388 let branding = BrandingConfig::new("Acme");
389 let ext = Some(Extension(Arc::new(DefaultBranding(branding))));
390 let got = default_branding_ref(&ext).expect("should unwrap");
391 assert_eq!(got.application_name, "Acme");
392 }
393
394 #[test]
395 fn branding_context_none_emits_allowthem_defaults() {
396 let v = branding_context(None);
397 assert_eq!(v.get_attr("app_name").unwrap().as_str(), Some("allowthem"));
398 assert_eq!(v.get_attr("accent").unwrap().as_str(), Some("#ffffff"));
399 assert_eq!(v.get_attr("accent_ink").unwrap().as_str(), Some("#000000"));
400 assert_eq!(
401 v.get_attr("accent_light").unwrap().as_str(),
402 Some("#000000")
403 );
404 assert_eq!(
405 v.get_attr("accent_ink_light").unwrap().as_str(),
406 Some("#ffffff")
407 );
408 assert!(v.get_attr("logo_url").unwrap().is_none());
409 assert!(v.get_attr("branding").is_ok());
411 }
412
413 #[test]
414 fn branding_context_some_projects_all_keys() {
415 let b = BrandingConfig::new("Fixture Co")
416 .with_accent("#ff00aa", AccentInk::Black)
417 .with_logo_url("https://cdn.example/logo.svg");
418 let v = branding_context(Some(&b));
419 assert_eq!(v.get_attr("app_name").unwrap().as_str(), Some("Fixture Co"));
420 assert_eq!(v.get_attr("accent").unwrap().as_str(), Some("#ff00aa"));
421 assert_eq!(v.get_attr("accent_ink").unwrap().as_str(), Some("#000000"));
422 assert_eq!(
423 v.get_attr("logo_url").unwrap().as_str(),
424 Some("https://cdn.example/logo.svg")
425 );
426 let inner = v.get_attr("branding").unwrap();
428 assert_eq!(
429 inner.get_attr("application_name").unwrap().as_str(),
430 Some("Fixture Co")
431 );
432 }
433}