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
160fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
161 let bytes = hex.as_bytes();
162 if bytes.len() != 7 || bytes[0] != b'#' {
163 return None;
164 }
165 let r = u8::from_str_radix(&hex[1..3], 16).ok()?;
166 let g = u8::from_str_radix(&hex[3..5], 16).ok()?;
167 let b = u8::from_str_radix(&hex[5..7], 16).ok()?;
168 Some((r, g, b))
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use allowthem_core::applications::BrandingConfig;
175 use allowthem_core::types::AccentInk;
176 use allowthem_core::{AllowThem, AllowThemBuilder};
177
178 async fn test_ath() -> AllowThem {
179 AllowThemBuilder::new("sqlite::memory:")
180 .cookie_secure(false)
181 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
182 .build()
183 .await
184 .unwrap()
185 }
186
187 #[tokio::test]
188 async fn resolve_branding_returns_default_when_client_id_is_none() {
189 let ath = test_ath().await;
190 let default = BrandingConfig::new("Fallback Co");
191 let result = resolve_branding(&ath, None, Some(&default)).await;
192 assert_eq!(
193 result.as_ref().map(|b| b.application_name.as_str()),
194 Some("Fallback Co")
195 );
196 }
197
198 #[tokio::test]
199 async fn resolve_branding_returns_none_when_no_client_and_no_default() {
200 let ath = test_ath().await;
201 let result = resolve_branding(&ath, None, None).await;
202 assert!(result.is_none());
203 }
204
205 #[tokio::test]
206 async fn resolve_branding_returns_default_when_client_id_does_not_match() {
207 let ath = test_ath().await;
208 let default = BrandingConfig::new("Fallback Co");
209 let unknown: allowthem_core::types::ClientId =
210 serde_json::from_str("\"ath_does_not_exist\"").unwrap();
211 let result = resolve_branding(&ath, Some(&unknown), 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 #[test]
219 fn derive_ink_pastels_pair_with_black() {
220 assert_eq!(derive_ink("#cba6f7"), AccentInk::Black);
222 assert_eq!(derive_ink("#fab387"), AccentInk::Black);
223 assert_eq!(derive_ink("#a6e3a1"), AccentInk::Black);
224 }
225
226 #[test]
227 fn derive_ink_saturated_darks_pair_with_white() {
228 assert_eq!(derive_ink("#5b21b6"), AccentInk::White);
230 assert_eq!(derive_ink("#1e1b4b"), AccentInk::White);
231 assert_eq!(derive_ink("#000000"), AccentInk::White);
232 }
233
234 #[test]
235 fn derive_ink_pure_white_pairs_with_black() {
236 assert_eq!(derive_ink("#ffffff"), AccentInk::Black);
237 }
238
239 #[test]
240 fn derive_ink_invalid_hex_defaults_to_white() {
241 assert_eq!(derive_ink("not-a-color"), AccentInk::White);
244 assert_eq!(derive_ink("#zz"), AccentInk::White);
245 }
246
247 #[test]
248 fn resolve_accent_defaults_without_branding() {
249 let (accent, ink, accent_light, ink_light) = resolve_accent(None);
250 assert_eq!(accent, "#ffffff");
251 assert_eq!(ink, "#000000");
252 assert_eq!(accent_light, "#000000");
253 assert_eq!(ink_light, "#ffffff");
254 }
255
256 #[test]
257 fn resolve_accent_branded_quad_pins_color_in_both_modes() {
258 let b = BrandingConfig {
259 application_name: "test".into(),
260 logo_url: None,
261 primary_color: None,
262 accent_hex: Some("#ff6600".into()),
263 accent_ink: None,
264 forced_mode: None,
265 font_css_url: None,
266 font_family: None,
267 splash_text: None,
268 splash_image_url: None,
269 splash_primitive: None,
270 splash_url: None,
271 shader_cell_scale: None,
272 title_brand: None,
273 };
274 let (accent, ink, accent_light, ink_light) = resolve_accent(Some(&b));
275 assert_eq!(accent, "#ff6600");
277 assert_eq!(accent_light, "#ff6600");
278 assert_eq!(ink, ink_light);
280 }
281
282 #[test]
283 fn resolve_accent_uses_accent_hex_over_primary_color() {
284 let b = BrandingConfig {
285 application_name: "test".into(),
286 logo_url: None,
287 primary_color: Some("#ff0000".into()),
288 accent_hex: Some("#00ff00".into()),
289 accent_ink: None,
290 forced_mode: None,
291 font_css_url: None,
292 font_family: None,
293 splash_text: None,
294 splash_image_url: None,
295 splash_primitive: None,
296 splash_url: None,
297 shader_cell_scale: None,
298 title_brand: None,
299 };
300 let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
301 assert_eq!(accent, "#00ff00");
302 }
303
304 #[test]
305 fn resolve_accent_falls_back_to_primary_color() {
306 let b = BrandingConfig {
307 application_name: "test".into(),
308 logo_url: None,
309 primary_color: Some("#ff0000".into()),
310 accent_hex: None,
311 accent_ink: None,
312 forced_mode: None,
313 font_css_url: None,
314 font_family: None,
315 splash_text: None,
316 splash_image_url: None,
317 splash_primitive: None,
318 splash_url: None,
319 shader_cell_scale: None,
320 title_brand: None,
321 };
322 let (accent, _ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
323 assert_eq!(accent, "#ff0000");
324 }
325
326 #[test]
327 fn resolve_accent_honors_explicit_ink() {
328 let b = BrandingConfig {
329 application_name: "test".into(),
330 logo_url: None,
331 primary_color: None,
332 accent_hex: Some("#ffffff".into()), accent_ink: Some(AccentInk::White), forced_mode: None,
335 font_css_url: None,
336 font_family: None,
337 splash_text: None,
338 splash_image_url: None,
339 splash_primitive: None,
340 splash_url: None,
341 shader_cell_scale: None,
342 title_brand: None,
343 };
344 let (_accent, ink, _accent_light, _ink_light) = resolve_accent(Some(&b));
345 assert_eq!(ink, "#ffffff");
346 }
347
348 #[test]
349 fn branding_ctx_none_gives_allowthem_defaults() {
350 let ctx = BrandingCtx::from_branding(None);
351 assert_eq!(ctx.app_name, "allowthem");
352 assert_eq!(ctx.title_brand, "allowthem");
353 assert_eq!(ctx.accent, "#ffffff");
354 assert_eq!(ctx.accent_ink, "#000000");
355 assert_eq!(ctx.accent_light, "#000000");
356 assert_eq!(ctx.accent_ink_light, "#ffffff");
357 assert!(ctx.logo_url.is_none());
358 }
359
360 #[test]
361 fn branding_ctx_some_projects_fields() {
362 let b = BrandingConfig::new("Fixture Co")
363 .with_accent("#ff00aa", AccentInk::Black)
364 .with_logo_url("https://cdn.example/logo.svg");
365 let ctx = BrandingCtx::from_branding(Some(&b));
366 assert_eq!(ctx.app_name, "Fixture Co");
367 assert_eq!(ctx.title_brand, "Fixture Co");
368 assert_eq!(ctx.accent, "#ff00aa");
369 assert_eq!(ctx.accent_ink, "#000000"); assert_eq!(ctx.logo_url, Some("https://cdn.example/logo.svg"));
371 }
372
373 #[test]
374 fn default_branding_ref_none_passes_through() {
375 let ext: Option<Extension<Arc<DefaultBranding>>> = None;
376 assert!(default_branding_ref(&ext).is_none());
377 }
378
379 #[test]
380 fn default_branding_ref_some_unwraps_to_inner_branding() {
381 let branding = BrandingConfig::new("Acme");
382 let ext = Some(Extension(Arc::new(DefaultBranding(branding))));
383 let got = default_branding_ref(&ext).expect("should unwrap");
384 assert_eq!(got.application_name, "Acme");
385 }
386
387 #[test]
388 fn branding_ctx_title_brand_defaults_to_app_name() {
389 let b = BrandingConfig::new("Acme Corp");
390 let ctx = BrandingCtx::from_branding(Some(&b));
391 assert_eq!(ctx.title_brand, "Acme Corp");
392 }
393
394 #[test]
395 fn branding_ctx_explicit_title_brand_overrides_app_name() {
396 let b = BrandingConfig::new("Acme Corp").with_title_brand("Acme");
397 let ctx = BrandingCtx::from_branding(Some(&b));
398 assert_eq!(ctx.app_name, "Acme Corp");
399 assert_eq!(ctx.title_brand, "Acme");
400 }
401}