1use std::sync::LazyLock;
61
62pub static WHITELABEL: LazyLock<WhitelabelConfig> = LazyLock::new(WhitelabelConfig::from_env);
67
68#[derive(Debug, Clone)]
72pub struct WhitelabelConfig {
73 pub name: String,
74 pub mark: String,
78 pub subtitle: String,
82 pub logo_url: Option<String>,
83 pub page_title: String,
84 pub parent_url: Option<String>,
85 pub parent_name: String,
86 pub api_docs_url: Option<String>,
89 pub css_url: Option<String>,
93 pub favicon_url: Option<String>,
97 pub default_namespace: String,
101}
102
103impl WhitelabelConfig {
104 pub fn is_customised(&self) -> bool {
109 self.name != "Assay"
110 || !self.subtitle.is_empty()
111 || self.logo_url.is_some()
112 || self.css_url.is_some()
113 || self.favicon_url.is_some()
114 }
115}
116
117impl WhitelabelConfig {
118 pub fn from_env() -> Self {
121 let name =
122 std::env::var("ASSAY_WHITELABEL_NAME").unwrap_or_else(|_| "Assay".to_string());
123 let mark = std::env::var("ASSAY_WHITELABEL_MARK")
131 .ok()
132 .filter(|s| !s.is_empty())
133 .unwrap_or_else(|| {
134 name.chars()
135 .next()
136 .map(|c| c.to_uppercase().to_string())
137 .unwrap_or_else(|| "A".to_string())
138 });
139 let subtitle =
140 std::env::var("ASSAY_WHITELABEL_SUBTITLE").unwrap_or_default();
141 let logo_url = std::env::var("ASSAY_WHITELABEL_LOGO_URL")
142 .ok()
143 .filter(|s| !s.is_empty());
144 let page_title = std::env::var("ASSAY_WHITELABEL_PAGE_TITLE")
145 .unwrap_or_else(|_| "Assay Workflow Dashboard".to_string());
146 let parent_url = std::env::var("ASSAY_WHITELABEL_PARENT_URL")
147 .ok()
148 .filter(|s| !s.is_empty());
149 let parent_name =
150 std::env::var("ASSAY_WHITELABEL_PARENT_NAME").unwrap_or_else(|_| "Back".to_string());
151 let api_docs_url = match std::env::var("ASSAY_WHITELABEL_API_DOCS_URL") {
152 Ok(s) if s.is_empty() => None,
153 Ok(s) => Some(s),
154 Err(_) => Some("/api/v1/docs".to_string()),
155 };
156 let css_url = std::env::var("ASSAY_WHITELABEL_CSS_URL")
157 .ok()
158 .filter(|s| !s.is_empty());
159 let favicon_url = std::env::var("ASSAY_WHITELABEL_FAVICON_URL")
160 .ok()
161 .filter(|s| !s.is_empty());
162 let default_namespace = std::env::var("ASSAY_WHITELABEL_DEFAULT_NAMESPACE")
163 .ok()
164 .filter(|s| !s.is_empty())
165 .unwrap_or_else(|| "main".to_string());
166 Self {
167 name,
168 mark,
169 subtitle,
170 logo_url,
171 page_title,
172 parent_url,
173 parent_name,
174 api_docs_url,
175 css_url,
176 favicon_url,
177 default_namespace,
178 }
179 }
180}
181
182fn html_escape(s: &str) -> String {
186 s.replace('&', "&")
187 .replace('<', "<")
188 .replace('>', ">")
189 .replace('"', """)
190 .replace('\'', "'")
191}
192
193pub fn render_index(template: &str, asset_version: &str, wl: &WhitelabelConfig) -> String {
201 let back_link = match &wl.parent_url {
202 Some(url) => format!(
203 r#"<a href="{}" class="nav-link nav-link-back" title="{}">
204 <span class="nav-icon">←</span> <span class="nav-label">{}</span>
205 </a>"#,
206 html_escape(url),
207 html_escape(&wl.parent_name),
208 html_escape(&wl.parent_name)
209 ),
210 None => String::new(),
211 };
212
213 let api_docs_link = match &wl.api_docs_url {
214 Some(url) => format!(
215 r#"<a href="{}" class="nav-link nav-link-grow" target="_blank">
216 <span class="nav-icon">📄</span> <span class="nav-label">API Docs</span>
217 </a>"#,
218 html_escape(url)
219 ),
220 None => String::new(),
221 };
222
223 let logo_img = match &wl.logo_url {
224 Some(url) => format!(
225 r#"<img class="logo-img" src="{}" alt="{}" />"#,
226 html_escape(url),
227 html_escape(&wl.name)
228 ),
229 None => String::new(),
230 };
231
232 let subtitle = if wl.subtitle.is_empty() {
236 String::new()
237 } else {
238 format!(
239 r#"<span class="logo-subtitle">{}</span>"#,
240 html_escape(&wl.subtitle)
241 )
242 };
243
244 let engine_footer = if wl.is_customised() {
251 r#"Powered by <a class="assay-attribution" href="https://assay.rs" target="_blank" rel="noopener noreferrer">Assay</a> <span id="status-version">—</span>"#.to_string()
252 } else {
253 r#"Assay Workflow Engine <span id="status-version">—</span>"#.to_string()
254 };
255
256 let favicon_link = match &wl.favicon_url {
261 Some(url) => format!(
262 r#"<link rel="icon" href="{}">"#,
263 html_escape(url)
264 ),
265 None => r#"<link rel="icon" type="image/svg+xml" href="/workflow/favicon.svg">"#.to_string(),
266 };
267
268 let default_namespace_attr = format!(
273 r#" data-default-namespace="{}""#,
274 html_escape(&wl.default_namespace)
275 );
276
277 let extra_css = match &wl.css_url {
282 Some(url) => {
283 let sep = if url.contains('?') { '&' } else { '?' };
284 format!(
285 r#"<link rel="stylesheet" href="{}{}v={}">"#,
286 html_escape(url),
287 sep,
288 asset_version
289 )
290 }
291 None => String::new(),
292 };
293
294 template
295 .replace("__ASSETV__", asset_version)
296 .replace("__PAGE_TITLE__", &html_escape(&wl.page_title))
297 .replace("__BRAND_NAME__", &html_escape(&wl.name))
298 .replace("__BRAND_MARK__", &html_escape(&wl.mark))
299 .replace("__BRAND_LOGO_IMG__", &logo_img)
300 .replace("__BRAND_SUBTITLE__", &subtitle)
301 .replace("__ENGINE_FOOTER__", &engine_footer)
302 .replace("__PARENT_BACK_LINK__", &back_link)
303 .replace("__API_DOCS_LINK__", &api_docs_link)
304 .replace("__EXTRA_CSS_LINK__", &extra_css)
305 .replace("__FAVICON_LINK__", &favicon_link)
306 .replace("__DEFAULT_NAMESPACE_ATTR__", &default_namespace_attr)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 const TEMPLATE: &str = r#"<title>__PAGE_TITLE__</title>
314<head>__FAVICON_LINK__ __EXTRA_CSS_LINK__</head>
315<body__DEFAULT_NAMESPACE_ATTR__>
316<span>__BRAND_NAME__</span><span>__BRAND_MARK__</span>
317__BRAND_SUBTITLE__
318__BRAND_LOGO_IMG__
319__PARENT_BACK_LINK__
320__API_DOCS_LINK__
321<footer>__ENGINE_FOOTER__</footer>
322v=__ASSETV__
323</body>"#;
324
325 fn default_cfg() -> WhitelabelConfig {
326 WhitelabelConfig {
327 name: "Assay".into(),
328 mark: "A".into(),
329 subtitle: String::new(),
330 logo_url: None,
331 page_title: "Assay Workflow Dashboard".into(),
332 parent_url: None,
333 parent_name: "Back".into(),
334 api_docs_url: Some("/api/v1/docs".into()),
335 css_url: None,
336 favicon_url: None,
337 default_namespace: "main".into(),
338 }
339 }
340
341 #[test]
342 fn default_render_matches_standalone_identity() {
343 let out = render_index(TEMPLATE, "42", &default_cfg());
344 assert!(out.contains("<title>Assay Workflow Dashboard</title>"));
345 assert!(out.contains("<span>Assay</span><span>A</span>"));
346 assert!(!out.contains("nav-link-back"));
348 assert!(!out.contains("logo-img"));
349 assert!(!out.contains("logo-subtitle"));
350 assert!(out.contains("Assay Workflow Engine"));
352 assert!(!out.contains("Powered by"));
353 assert!(!out.contains("assay-attribution"));
354 assert!(out.contains("href=\"/api/v1/docs\""));
356 assert!(out.contains(r#"href="/workflow/favicon.svg""#));
358 assert!(out.contains(r#"data-default-namespace="main""#));
360 assert!(out.contains("v=42"));
362 }
363
364 #[test]
365 fn subtitle_renders_as_muted_span_when_set() {
366 let mut cfg = default_cfg();
367 cfg.name = "SIMONS".into();
368 cfg.subtitle = "Command Center".into();
369 let out = render_index(TEMPLATE, "v", &cfg);
370 assert!(out.contains(r#"<span class="logo-subtitle">Command Center</span>"#));
371 }
372
373 #[test]
374 fn subtitle_unset_emits_nothing() {
375 let out = render_index(TEMPLATE, "v", &default_cfg());
376 assert!(!out.contains("logo-subtitle"));
377 }
378
379 #[test]
380 fn mark_override_wins_over_name_initial() {
381 let mut cfg = default_cfg();
382 cfg.name = "The Platform".into();
383 cfg.mark = "P".into();
384 let out = render_index(TEMPLATE, "v", &cfg);
385 assert!(out.contains("<span>The Platform</span><span>P</span>"));
387 }
388
389 #[test]
390 fn whitelabel_footer_includes_powered_by_and_attribution_link() {
391 let mut cfg = default_cfg();
394 cfg.name = "Acme Workflows".into();
395 cfg.mark = "A".into();
396 let out = render_index(TEMPLATE, "v", &cfg);
397 assert!(out.contains("Powered by"));
398 assert!(out.contains(r#">Assay</a>"#), "short 'Assay' attribution text");
399 assert!(!out.contains("Workflow Engine</a>"), "should not say 'Assay Workflow Engine' in link");
400 assert!(out.contains(r#"href="https://assay.rs""#));
401 assert!(out.contains(r#"target="_blank""#));
402 assert!(out.contains(r#"rel="noopener noreferrer""#));
403 assert!(out.contains(r#"<span id="status-version">—</span>"#));
405 }
406
407 #[test]
408 fn favicon_url_override_emits_operator_link_tag() {
409 let mut cfg = default_cfg();
410 cfg.favicon_url = Some("/static/acme-favicon.ico".into());
411 let out = render_index(TEMPLATE, "v", &cfg);
412 assert!(out.contains(r#"href="/static/acme-favicon.ico""#));
413 assert!(!out.contains(r#"href="/workflow/favicon.svg""#));
414 }
415
416 #[test]
417 fn default_namespace_override_threads_into_data_attr() {
418 let mut cfg = default_cfg();
419 cfg.default_namespace = "deployments".into();
420 let out = render_index(TEMPLATE, "v", &cfg);
421 assert!(out.contains(r#"data-default-namespace="deployments""#));
422 }
423
424 #[test]
425 fn favicon_override_flips_footer_attribution_too() {
426 let mut cfg = default_cfg();
429 cfg.favicon_url = Some("/f.ico".into());
430 let out = render_index(TEMPLATE, "v", &cfg);
431 assert!(out.contains("Powered by"));
432 }
433
434 #[test]
435 fn customised_detection_requires_more_than_defaults() {
436 let base = default_cfg();
437 assert!(!base.is_customised(), "stock defaults must not read as customised");
438
439 let mut with_name = default_cfg();
440 with_name.name = "Acme".into();
441 assert!(with_name.is_customised());
442
443 let mut with_subtitle = default_cfg();
444 with_subtitle.subtitle = "Something".into();
445 assert!(with_subtitle.is_customised());
446
447 let mut with_logo = default_cfg();
448 with_logo.logo_url = Some("/l.svg".into());
449 assert!(with_logo.is_customised());
450
451 let mut with_css = default_cfg();
452 with_css.css_url = Some("/t.css".into());
453 assert!(with_css.is_customised());
454 }
455
456 #[test]
457 fn whitelabel_name_and_mark_are_substituted() {
458 let mut cfg = default_cfg();
459 cfg.name = "Command Center".into();
460 cfg.mark = "C".into();
461 let out = render_index(TEMPLATE, "v", &cfg);
462 assert!(out.contains("<span>Command Center</span><span>C</span>"));
463 }
464
465 #[test]
466 fn logo_url_renders_img_tag() {
467 let mut cfg = default_cfg();
468 cfg.logo_url = Some("/static/siemens-logo.png".into());
469 cfg.name = "CC".into();
470 let out = render_index(TEMPLATE, "v", &cfg);
471 assert!(out.contains(r#"src="/static/siemens-logo.png""#));
472 assert!(out.contains(r#"alt="CC""#));
473 }
474
475 #[test]
476 fn parent_url_renders_back_link() {
477 let mut cfg = default_cfg();
478 cfg.parent_url = Some("https://command.example/".into());
479 cfg.parent_name = "Command Center".into();
480 let out = render_index(TEMPLATE, "v", &cfg);
481 assert!(out.contains(r#"href="https://command.example/""#));
482 assert!(out.contains("Command Center"));
483 assert!(out.contains("nav-link-back"));
484 }
485
486 #[test]
487 fn api_docs_empty_hides_the_link() {
488 let mut cfg = default_cfg();
489 cfg.api_docs_url = None;
490 let out = render_index(TEMPLATE, "v", &cfg);
491 assert!(!out.contains("API Docs"));
492 assert!(!out.contains("/api/v1/docs"));
493 }
494
495 #[test]
496 fn api_docs_override_retargets_the_link() {
497 let mut cfg = default_cfg();
498 cfg.api_docs_url = Some("https://docs.example/api".into());
499 let out = render_index(TEMPLATE, "v", &cfg);
500 assert!(out.contains(r#"href="https://docs.example/api""#));
501 assert!(out.contains("API Docs"));
502 }
503
504 #[test]
505 fn css_url_unset_emits_no_extra_stylesheet() {
506 let out = render_index(TEMPLATE, "42", &default_cfg());
507 assert!(
508 !out.contains("rel=\"stylesheet\""),
509 "no extra stylesheet should render when ASSAY_WHITELABEL_CSS_URL is unset"
510 );
511 }
512
513 #[test]
514 fn css_url_emits_cache_busted_link_tag() {
515 let mut cfg = default_cfg();
516 cfg.css_url = Some("/static/cc-theme.css".into());
517 let out = render_index(TEMPLATE, "42", &cfg);
518 assert!(out.contains(r#"<link rel="stylesheet" href="/static/cc-theme.css?v=42">"#));
519 }
520
521 #[test]
522 fn css_url_with_existing_query_string_uses_ampersand() {
523 let mut cfg = default_cfg();
527 cfg.css_url = Some("/static/cc-theme.css?rev=abc".into());
528 let out = render_index(TEMPLATE, "42", &cfg);
529 assert!(out.contains("href=\"/static/cc-theme.css?rev=abc&v=42\""));
530 }
531
532 #[test]
533 fn html_in_brand_name_is_escaped() {
534 let mut cfg = default_cfg();
535 cfg.name = "Acme <Inc>".into();
536 let out = render_index(TEMPLATE, "v", &cfg);
537 assert!(out.contains("Acme <Inc>"));
538 assert!(!out.contains("<Inc>"), "raw angle brackets must not land in the HTML");
539 }
540}