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 title_brand: &'a str,
119 pub accent: String,
120 pub accent_ink: &'static str,
121 pub accent_light: String,
122 pub accent_ink_light: &'static str,
123 pub logo_url: Option<&'a str>,
124}
125
126impl<'a> BrandingCtx<'a> {
127 pub fn from_branding(branding: Option<&'a BrandingConfig>) -> Self {
128 let (accent, accent_ink, accent_light, accent_ink_light) = resolve_accent(branding);
129 let app_name = branding
130 .map(|b| b.application_name.as_str())
131 .unwrap_or("allowthem");
132 let title_brand = branding
133 .and_then(|b| b.title_brand.as_deref())
134 .unwrap_or(app_name);
135 Self {
136 app_name,
137 title_brand,
138 accent,
139 accent_ink,
140 accent_light,
141 accent_ink_light,
142 logo_url: branding.and_then(|b| b.logo_url.as_deref()),
143 }
144 }
145}
146
147pub fn default_branding_ref(
155 ext: &Option<Extension<Arc<DefaultBranding>>>,
156) -> Option<&BrandingConfig> {
157 ext.as_ref().map(|Extension(d)| &d.0)
158}
159
160pub fn branding_context(branding: Option<&BrandingConfig>) -> minijinja::Value {
167 let ctx = BrandingCtx::from_branding(branding);
168 minijinja::context! {
169 branding,
170 app_name => ctx.app_name,
171 title_brand => ctx.title_brand,
172 logo_url => ctx.logo_url,
173 accent => ctx.accent,
174 accent_ink => ctx.accent_ink,
175 accent_light => ctx.accent_light,
176 accent_ink_light => ctx.accent_ink_light,
177 }
178}
179
180fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
181 let bytes = hex.as_bytes();
182 if bytes.len() != 7 || bytes[0] != b'#' {
183 return None;
184 }
185 let r = u8::from_str_radix(&hex[1..3], 16).ok()?;
186 let g = u8::from_str_radix(&hex[3..5], 16).ok()?;
187 let b = u8::from_str_radix(&hex[5..7], 16).ok()?;
188 Some((r, g, b))
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use allowthem_core::applications::BrandingConfig;
195 use allowthem_core::types::AccentInk;
196 use allowthem_core::{AllowThem, AllowThemBuilder};
197
198 async fn test_ath() -> AllowThem {
199 AllowThemBuilder::new("sqlite::memory:")
200 .cookie_secure(false)
201 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
202 .build()
203 .await
204 .unwrap()
205 }
206
207 #[tokio::test]
208 async fn resolve_branding_returns_default_when_client_id_is_none() {
209 let ath = test_ath().await;
210 let default = BrandingConfig::new("Fallback Co");
211 let result = resolve_branding(&ath, None, Some(&default)).await;
212 assert_eq!(
213 result.as_ref().map(|b| b.application_name.as_str()),
214 Some("Fallback Co")
215 );
216 }
217
218 #[tokio::test]
219 async fn resolve_branding_returns_none_when_no_client_and_no_default() {
220 let ath = test_ath().await;
221 let result = resolve_branding(&ath, None, None).await;
222 assert!(result.is_none());
223 }
224
225 #[tokio::test]
226 async fn resolve_branding_returns_default_when_client_id_does_not_match() {
227 let ath = test_ath().await;
228 let default = BrandingConfig::new("Fallback Co");
229 let unknown: allowthem_core::types::ClientId =
230 serde_json::from_str("\"ath_does_not_exist\"").unwrap();
231 let result = resolve_branding(&ath, Some(&unknown), Some(&default)).await;
232 assert_eq!(
233 result.as_ref().map(|b| b.application_name.as_str()),
234 Some("Fallback Co")
235 );
236 }
237
238 #[test]
239 fn derive_ink_pastels_pair_with_black() {
240 assert_eq!(derive_ink("#cba6f7"), AccentInk::Black);
242 assert_eq!(derive_ink("#fab387"), AccentInk::Black);
243 assert_eq!(derive_ink("#a6e3a1"), AccentInk::Black);
244 }
245
246 #[test]
247 fn derive_ink_saturated_darks_pair_with_white() {
248 assert_eq!(derive_ink("#5b21b6"), AccentInk::White);
250 assert_eq!(derive_ink("#1e1b4b"), AccentInk::White);
251 assert_eq!(derive_ink("#000000"), AccentInk::White);
252 }
253
254 #[test]
255 fn derive_ink_pure_white_pairs_with_black() {
256 assert_eq!(derive_ink("#ffffff"), AccentInk::Black);
257 }
258
259 #[test]
260 fn derive_ink_invalid_hex_defaults_to_white() {
261 assert_eq!(derive_ink("not-a-color"), AccentInk::White);
264 assert_eq!(derive_ink("#zz"), AccentInk::White);
265 }
266
267 #[test]
268 fn resolve_accent_defaults_without_branding() {
269 let (accent, ink, accent_light, ink_light) = resolve_accent(None);
270 assert_eq!(accent, "#ffffff");
271 assert_eq!(ink, "#000000");
272 assert_eq!(accent_light, "#000000");
273 assert_eq!(ink_light, "#ffffff");
274 }
275
276 #[test]
277 fn resolve_accent_branded_quad_pins_color_in_both_modes() {
278 let b = BrandingConfig {
279 application_name: "test".into(),
280 logo_url: None,
281 primary_color: None,
282 accent_hex: Some("#ff6600".into()),
283 accent_ink: None,
284 forced_mode: None,
285 font_css_url: None,
286 font_family: None,
287 splash_text: None,
288 splash_image_url: None,
289 splash_primitive: None,
290 splash_url: None,
291 shader_cell_scale: None,
292 title_brand: None,
293 };
294 let (accent, ink, accent_light, ink_light) = resolve_accent(Some(&b));
295 assert_eq!(accent, "#ff6600");
297 assert_eq!(accent_light, "#ff6600");
298 assert_eq!(ink, ink_light);
300 }
301
302 #[test]
303 fn resolve_accent_uses_accent_hex_over_primary_color() {
304 let b = BrandingConfig {
305 application_name: "test".into(),
306 logo_url: None,
307 primary_color: Some("#ff0000".into()),
308 accent_hex: Some("#00ff00".into()),
309 accent_ink: None,
310 forced_mode: None,
311 font_css_url: None,
312 font_family: None,
313 splash_text: None,
314 splash_image_url: None,
315 splash_primitive: None,
316 splash_url: None,
317 shader_cell_scale: None,
318 title_brand: None,
319 };
320 let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
321 assert_eq!(accent, "#00ff00");
322 }
323
324 #[test]
325 fn resolve_accent_falls_back_to_primary_color() {
326 let b = BrandingConfig {
327 application_name: "test".into(),
328 logo_url: None,
329 primary_color: Some("#ff0000".into()),
330 accent_hex: None,
331 accent_ink: None,
332 forced_mode: None,
333 font_css_url: None,
334 font_family: None,
335 splash_text: None,
336 splash_image_url: None,
337 splash_primitive: None,
338 splash_url: None,
339 shader_cell_scale: None,
340 title_brand: None,
341 };
342 let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
343 assert_eq!(accent, "#ff0000");
344 }
345
346 #[test]
347 fn resolve_accent_honors_explicit_ink() {
348 let b = BrandingConfig {
349 application_name: "test".into(),
350 logo_url: None,
351 primary_color: None,
352 accent_hex: Some("#ffffff".into()), accent_ink: Some(AccentInk::White), forced_mode: None,
355 font_css_url: None,
356 font_family: None,
357 splash_text: None,
358 splash_image_url: None,
359 splash_primitive: None,
360 splash_url: None,
361 shader_cell_scale: None,
362 title_brand: None,
363 };
364 let (_accent, ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
365 assert_eq!(ink, "#ffffff");
366 }
367
368 #[test]
369 fn branding_ctx_none_gives_allowthem_defaults() {
370 let ctx = BrandingCtx::from_branding(None);
371 assert_eq!(ctx.app_name, "allowthem");
372 assert_eq!(ctx.title_brand, "allowthem");
373 assert_eq!(ctx.accent, "#ffffff");
374 assert_eq!(ctx.accent_ink, "#000000");
375 assert_eq!(ctx.accent_light, "#000000");
376 assert_eq!(ctx.accent_ink_light, "#ffffff");
377 assert!(ctx.logo_url.is_none());
378 }
379
380 #[test]
381 fn branding_ctx_some_projects_fields() {
382 let b = BrandingConfig::new("Fixture Co")
383 .with_accent("#ff00aa", AccentInk::Black)
384 .with_logo_url("https://cdn.example/logo.svg");
385 let ctx = BrandingCtx::from_branding(Some(&b));
386 assert_eq!(ctx.app_name, "Fixture Co");
387 assert_eq!(ctx.title_brand, "Fixture Co");
388 assert_eq!(ctx.accent, "#ff00aa");
389 assert_eq!(ctx.accent_ink, "#000000"); assert_eq!(ctx.logo_url, Some("https://cdn.example/logo.svg"));
391 }
392
393 #[test]
394 fn default_branding_ref_none_passes_through() {
395 let ext: Option<Extension<Arc<DefaultBranding>>> = None;
396 assert!(default_branding_ref(&ext).is_none());
397 }
398
399 #[test]
400 fn default_branding_ref_some_unwraps_to_inner_branding() {
401 let branding = BrandingConfig::new("Acme");
402 let ext = Some(Extension(Arc::new(DefaultBranding(branding))));
403 let got = default_branding_ref(&ext).expect("should unwrap");
404 assert_eq!(got.application_name, "Acme");
405 }
406
407 #[test]
408 fn branding_ctx_title_brand_defaults_to_app_name() {
409 let b = BrandingConfig::new("Acme Corp");
410 let ctx = BrandingCtx::from_branding(Some(&b));
411 assert_eq!(ctx.title_brand, "Acme Corp");
412 }
413
414 #[test]
415 fn branding_ctx_explicit_title_brand_overrides_app_name() {
416 let b = BrandingConfig::new("Acme Corp").with_title_brand("Acme");
417 let ctx = BrandingCtx::from_branding(Some(&b));
418 assert_eq!(ctx.app_name, "Acme Corp");
419 assert_eq!(ctx.title_brand, "Acme");
420 }
421
422 #[test]
423 fn branding_context_none_emits_allowthem_defaults() {
424 let v = branding_context(None);
425 assert_eq!(v.get_attr("app_name").unwrap().as_str(), Some("allowthem"));
426 assert_eq!(
427 v.get_attr("title_brand").unwrap().as_str(),
428 Some("allowthem")
429 );
430 assert_eq!(v.get_attr("accent").unwrap().as_str(), Some("#ffffff"));
431 assert_eq!(v.get_attr("accent_ink").unwrap().as_str(), Some("#000000"));
432 assert_eq!(
433 v.get_attr("accent_light").unwrap().as_str(),
434 Some("#000000")
435 );
436 assert_eq!(
437 v.get_attr("accent_ink_light").unwrap().as_str(),
438 Some("#ffffff")
439 );
440 assert!(v.get_attr("logo_url").unwrap().is_none());
441 assert!(v.get_attr("branding").is_ok());
443 }
444
445 #[test]
446 fn branding_context_some_projects_all_keys() {
447 let b = BrandingConfig::new("Fixture Co")
448 .with_accent("#ff00aa", AccentInk::Black)
449 .with_logo_url("https://cdn.example/logo.svg");
450 let v = branding_context(Some(&b));
451 assert_eq!(v.get_attr("app_name").unwrap().as_str(), Some("Fixture Co"));
452 assert_eq!(v.get_attr("accent").unwrap().as_str(), Some("#ff00aa"));
453 assert_eq!(v.get_attr("accent_ink").unwrap().as_str(), Some("#000000"));
454 assert_eq!(
455 v.get_attr("logo_url").unwrap().as_str(),
456 Some("https://cdn.example/logo.svg")
457 );
458 let inner = v.get_attr("branding").unwrap();
460 assert_eq!(
461 inner.get_attr("application_name").unwrap().as_str(),
462 Some("Fixture Co")
463 );
464 }
465}