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 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
141/// Flatten the embedder default-branding extension into the plain reference
142/// form handlers need to feed into `resolve_branding`.
143///
144/// Handlers declare the extractor as
145/// `Option<Extension<Arc<DefaultBranding>>>`; they all then need the inner
146/// `&BrandingConfig`. This helper removes the per-site `as_ref().map(|Extension(d)| &d.0)`
147/// boilerplate.
148pub fn default_branding_ref(
149    ext: &Option<Extension<Arc<DefaultBranding>>>,
150) -> Option<&BrandingConfig> {
151    ext.as_ref().map(|Extension(d)| &d.0)
152}
153
154/// Project branding into the flat template keys every pre-auth page reads:
155/// `branding` (raw, for dotted access to `splash_*`/`forced_mode`/`font_*`),
156/// `app_name`, `logo_url`, and the accent quad.
157///
158/// Use with minijinja's spread syntax to compose with page-specific keys:
159/// `context! { ..branding_context(b), csrf_token, next, ... }`.
160pub 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        // Pastel violet, pastel peach, pastel mint — all light enough for black.
234        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        // Deep purple, indigo, near-black — need white ink.
242        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        // YIQ of an unknown color shouldn't panic; default to White ink
255        // (accent interpreted as near-black).
256        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        // Same brand color in both modes — theme toggles must not clobber it.
288        assert_eq!(accent, "#ff6600");
289        assert_eq!(accent_light, "#ff6600");
290        // YIQ-derived ink is stable across the symmetric call sites.
291        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()), // would derive Black ink
343            accent_ink: Some(AccentInk::White), // but explicitly White
344            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"); // YIQ pastel → black ink
377        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        // `branding` key must still be present (raw, for dotted access).
410        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        // `branding` serializes the raw struct — dotted access should work.
427        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}