Skip to main content

allowthem_server/
branding.rs

1use std::sync::Arc;
2
3use allowthem_core::AllowThem;
4use allowthem_core::applications::BrandingConfig;
5use allowthem_core::types::{AccentInk, ClientId};
6use axum::Extension;
7
8/// Default allowthem accent (white on dark; black on light).
9pub const DEFAULT_ACCENT_HEX: &str = "#ffffff";
10
11/// Pick an AAA-safe text color to pair with an accent fill.
12///
13/// Uses the classic YIQ luminance formula. Threshold 160 was chosen against
14/// the standard Wave Funk pastel palette and a fixture of 20 accents: it
15/// keeps every pastel above the line (black text) and every saturated deep
16/// color below (white text). Invalid hex falls back to white ink — safest
17/// for an accent we can't reason about.
18pub 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
32/// Resolve the accent quad `(accent_dark, accent_ink_dark, accent_light,
33/// accent_ink_light)` for template emission across both color modes.
34///
35/// Falls back to allowthem's monochrome default when the integrator has no
36/// branding or no accent set. The default inverts between modes so contrast
37/// is AAA either way: white-on-black in dark mode, black-on-white in light
38/// mode. When the integrator sets an accent, the same brand color is used
39/// in both modes (with YIQ-derived ink) so theme toggles never clobber it.
40/// The light-mode pair is computed symmetrically to the dark-mode pair so a
41/// future `accent_hex_light` override can slot in without signature churn.
42/// Also reads the legacy `primary_color` field so existing tenants keep
43/// working until they migrate to `accent_hex`.
44pub 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
69/// Look up branding for an application by client_id.
70///
71/// Returns `None` for missing or inactive applications.
72/// Logs a warning on unexpected DB errors and falls back to `None`.
73pub 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/// Embedder-provided fallback branding, attached to the router via
88/// `Extension<Arc<DefaultBranding>>` when the embedder calls
89/// `AllRoutesBuilder::default_branding`.
90///
91/// Wrapping the `BrandingConfig` in a newtype keeps it disjoint from any
92/// handler that takes `Extension<BrandingConfig>` directly.
93#[derive(Debug, Clone)]
94pub struct DefaultBranding(pub BrandingConfig);
95
96/// Resolve branding for a handler: per-client row if the lookup matches,
97/// else the embedder-supplied default, else `None`.
98pub 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
109/// Projection of `BrandingConfig` into the flat context keys every pre-auth
110/// template reads directly (not via `branding.*` dotted access): `app_name`,
111/// `logo_url`, and the accent quad.
112///
113/// Handlers also emit `branding => branding` as a separate context key so
114/// templates keep their existing dotted access to `splash_*`, `forced_mode`,
115/// and `font_*` fields.
116pub 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
147/// Flatten the embedder default-branding extension into the plain reference
148/// form handlers need to feed into `resolve_branding`.
149///
150/// Handlers declare the extractor as
151/// `Option<Extension<Arc<DefaultBranding>>>`; they all then need the inner
152/// `&BrandingConfig`. This helper removes the per-site `as_ref().map(|Extension(d)| &d.0)`
153/// boilerplate.
154pub fn default_branding_ref(
155    ext: &Option<Extension<Arc<DefaultBranding>>>,
156) -> Option<&BrandingConfig> {
157    ext.as_ref().map(|Extension(d)| &d.0)
158}
159
160/// Project branding into the flat template keys every pre-auth page reads:
161/// `branding` (raw, for dotted access to `splash_*`/`forced_mode`/`font_*`),
162/// `app_name`, `logo_url`, and the accent quad.
163///
164/// Use with minijinja's spread syntax to compose with page-specific keys:
165/// `context! { ..branding_context(b), csrf_token, next, ... }`.
166pub 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        // Pastel violet, pastel peach, pastel mint — all light enough for black.
241        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        // Deep purple, indigo, near-black — need white ink.
249        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        // YIQ of an unknown color shouldn't panic; default to White ink
262        // (accent interpreted as near-black).
263        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        // Same brand color in both modes — theme toggles must not clobber it.
296        assert_eq!(accent, "#ff6600");
297        assert_eq!(accent_light, "#ff6600");
298        // YIQ-derived ink is stable across the symmetric call sites.
299        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()), // would derive Black ink
353            accent_ink: Some(AccentInk::White), // but explicitly White
354            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"); // YIQ pastel → black ink
390        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        // `branding` key must still be present (raw, for dotted access).
442        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        // `branding` serializes the raw struct — dotted access should work.
459        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}