#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Nav {
Home,
Csaf,
Import,
Export,
Settings,
Info,
}
impl Nav {
const fn as_str(self) -> &'static str {
match self {
Self::Home => "home",
Self::Csaf => "csaf",
Self::Import => "import",
Self::Export => "export",
Self::Settings => "settings",
Self::Info => "info",
}
}
}
#[must_use]
pub fn wrap_page(title: &str, theme: &str, active: Nav, content: &str) -> String {
let theme = if theme == "dark" { "dark" } else { "light" };
let active_key = active.as_str();
format!(
r#"<!DOCTYPE html>
<html lang="en" data-bs-theme="{theme}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} — CSAF</title>
<style>
@font-face {{
font-family: "Roboto";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/roboto/roboto-regular.ttf") format("truetype");
}}
@font-face {{
font-family: "Roboto";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/roboto/roboto-bold.ttf") format("truetype");
}}
@font-face {{
font-family: "Roboto Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/static/fonts/roboto-mono/roboto-mono-regular.ttf") format("truetype");
}}
@font-face {{
font-family: "Roboto Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/static/fonts/roboto-mono/roboto-mono-bold.ttf") format("truetype");
}}
:root {{
--bg: #f5f5f5;
--surface: #ffffff;
--text: #1a1a1a;
--muted: #555555;
--border: #e0e0e0;
--primary: #8b1a1a;
--primary-hover: #a0252a;
--primary-contrast: #ffffff;
--link: #8b1a1a;
--row-hover: #f7f7f9;
--input-bg: #ffffff;
--input-border: #bdbdbd;
--shadow: 0 1px 3px rgba(0,0,0,0.08);
--badge-create-bg: #e8f5e9; --badge-create-fg: #2e7d32;
--badge-update-bg: #fff3e0; --badge-update-fg: #e65100;
--badge-delete-bg: #fce4ec; --badge-delete-fg: #c62828;
--badge-import-bg: #f3e5f5; --badge-import-fg: #6a1b9a;
--badge-export-bg: #ede7f6; --badge-export-fg: #4527a0;
--sev-critical: #b71c1c; --sev-high: #ef6c00; --sev-medium: #f9a825;
--sev-low: #2e7d32; --sev-none: #616161;
}}
html[data-bs-theme="dark"] {{
--bg: #0f1115;
--surface: #181b22;
--text: #e9ecef;
--muted: #9aa0a6;
--border: #2a2f3a;
--primary: #c44545;
--primary-hover: #d85c5c;
--primary-contrast: #ffffff;
--link: #e89696;
--row-hover: #1f232c;
--input-bg: #12151b;
--input-border: #394150;
--shadow: 0 1px 3px rgba(0,0,0,0.5);
--badge-create-bg: #1b3a24; --badge-create-fg: #9ef5b6;
--badge-update-bg: #4a2e10; --badge-update-fg: #ffc889;
--badge-delete-bg: #4a1c29; --badge-delete-fg: #ff98b3;
--badge-import-bg: #3a1f4d; --badge-import-fg: #d9a6ff;
--badge-export-bg: #221a4a; --badge-export-fg: #b2a4ff;
}}
* {{ box-sizing: border-box; }}
html, body {{
font-family: "Roboto", system-ui, -apple-system, "Segoe UI", sans-serif;
}}
code, pre, kbd, samp, tt {{
font-family: "Roboto Mono", "SF Mono", Menlo, Consolas, monospace;
}}
body {{
margin: 0; padding: 0; background: var(--bg); color: var(--text);
line-height: 1.5;
font-weight: 400;
}}
nav.primary {{
background: var(--primary); color: var(--primary-contrast);
padding: 0.6rem 1.5rem; display: flex; align-items: center;
gap: 1.25rem; flex-wrap: wrap;
border-bottom: 3px solid rgba(0,0,0,0.15);
}}
nav.primary a {{
color: var(--primary-contrast); text-decoration: none;
font-size: 0.9rem; padding: 0.35rem 0.6rem; border-radius: 4px;
}}
nav.primary a:hover {{ background: rgba(255,255,255,0.14); }}
nav.primary a.active {{ background: rgba(255,255,255,0.22); font-weight: 600; }}
nav.primary .brand {{
display: flex; align-items: center; gap: 0.6rem;
margin-right: 0.75rem; text-decoration: none;
}}
nav.primary .brand img {{
height: 36px; width: auto; display: block;
background: #ffffff; padding: 4px 8px; border-radius: 4px;
}}
nav.primary .brand .brand-text {{
font-weight: 700; font-size: 1.2rem; letter-spacing: 0.02em;
color: var(--primary-contrast);
}}
nav.primary .spacer {{ flex: 1; }}
nav.primary .theme-toggle {{
background: transparent; border: 1px solid rgba(255,255,255,0.5);
color: var(--primary-contrast); padding: 0.35rem 0.75rem; border-radius: 4px;
cursor: pointer; font-size: 0.85rem; font-family: inherit;
}}
nav.primary .theme-toggle:hover {{ background: rgba(255,255,255,0.14); }}
main {{ max-width: 1200px; margin: 2rem auto; padding: 0 1.5rem; }}
h1, h2, h3 {{ color: var(--text); }}
h1 {{ margin-top: 0; }}
a {{ color: var(--link); }}
.card {{
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;
box-shadow: var(--shadow);
}}
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }}
.stat {{ text-align: center; }}
.stat .number {{ font-size: 2rem; font-weight: bold; color: var(--primary); }}
.stat .label {{ font-size: 0.85rem; color: var(--muted); margin-top: 0.25rem; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{
text-align: left; padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--border); font-size: 0.9rem;
}}
th {{ background: var(--row-hover); font-weight: 600; color: var(--muted); }}
tr:hover td {{ background: var(--row-hover); }}
label {{ display: block; margin-bottom: 0.35rem; font-weight: 500; font-size: 0.9rem; }}
input[type=text], input[type=number], input[type=url], input[type=email],
select, textarea {{
width: 100%; padding: 0.5rem 0.65rem; border: 1px solid var(--input-border);
border-radius: 4px; background: var(--input-bg); color: var(--text);
font-family: inherit; font-size: 0.9rem;
}}
textarea {{ font-family: "Roboto Mono", "SF Mono", Menlo, monospace; font-size: 0.85rem; }}
.form-row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }}
.form-row.single {{ grid-template-columns: 1fr; }}
.btn {{
display: inline-block; padding: 0.55rem 1.1rem; border: none; border-radius: 4px;
background: var(--primary); color: var(--primary-contrast); font-size: 0.9rem;
font-weight: 500; cursor: pointer; text-decoration: none;
}}
.btn:hover {{ filter: brightness(1.1); }}
.btn.secondary {{ background: transparent; color: var(--link); border: 1px solid var(--border); }}
.btn.danger {{ background: #c62828; }}
.badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; font-weight: 500; }}
.badge-create {{ background: var(--badge-create-bg); color: var(--badge-create-fg); }}
.badge-update {{ background: var(--badge-update-bg); color: var(--badge-update-fg); }}
.badge-delete {{ background: var(--badge-delete-bg); color: var(--badge-delete-fg); }}
.badge-import {{ background: var(--badge-import-bg); color: var(--badge-import-fg); }}
.badge-export {{ background: var(--badge-export-bg); color: var(--badge-export-fg); }}
.sev-critical {{ color: var(--sev-critical); font-weight: 600; }}
.sev-high {{ color: var(--sev-high); font-weight: 600; }}
.sev-medium {{ color: var(--sev-medium); font-weight: 600; }}
.sev-low {{ color: var(--sev-low); font-weight: 600; }}
.sev-none {{ color: var(--sev-none); }}
.flash {{ padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }}
.flash.success {{ background: var(--badge-create-bg); color: var(--badge-create-fg); }}
.flash.error {{ background: var(--badge-delete-bg); color: var(--badge-delete-fg); }}
.muted {{ color: var(--muted); font-size: 0.85rem; }}
/* TLP 2.0 color-coding per https://www.first.org/tlp/
Background + font colours taken from the FIRST spec. */
.tlp-select {{ font-weight: 600; }}
.tlp-select.tlp-red {{ background: #FF2B2B; color: #000; }}
.tlp-select.tlp-amber,
.tlp-select.tlp-amber-strict {{ background: #FFC000; color: #000; }}
.tlp-select.tlp-green {{ background: #33FF00; color: #000; }}
.tlp-select.tlp-clear {{ background: #FFFFFF; color: #000;
border: 1px solid var(--input-border); }}
.tlp-select.tlp-unknown {{ background: var(--input-bg); color: var(--text); }}
.tlp-select option[value="RED"] {{ background: #FF2B2B; color: #000; }}
.tlp-select option[value="AMBER"],
.tlp-select option[value="AMBER+STRICT"] {{ background: #FFC000; color: #000; }}
.tlp-select option[value="GREEN"] {{ background: #33FF00; color: #000; }}
.tlp-select option[value="CLEAR"] {{ background: #FFFFFF; color: #000; }}
footer {{ text-align: center; padding: 2rem 1rem; color: var(--muted); font-size: 0.8rem; }}
</style>
<script>
// Sync the background colour of every .tlp-select to the current value.
// Accepted labels: CLEAR, GREEN, AMBER, AMBER+STRICT, RED. Anything
// else maps to `tlp-unknown` so hostile document values cannot inject
// arbitrary class names.
document.addEventListener("DOMContentLoaded", function() {{
var allowed = {{
"CLEAR": "tlp-clear",
"GREEN": "tlp-green",
"AMBER": "tlp-amber",
"AMBER+STRICT": "tlp-amber-strict",
"RED": "tlp-red"
}};
document.querySelectorAll(".tlp-select").forEach(function(sel) {{
var sync = function() {{
var key = allowed[sel.value] ? allowed[sel.value] : "tlp-unknown";
sel.className = "tlp-select " + key;
}};
sel.addEventListener("change", sync);
sync();
}});
}});
</script>
</head>
<body>
<nav class="primary">
<a href="/" class="brand" aria-label="ndaal CSAF home">
<img src="/static/img/logo.png" alt="ndaal — information security & compliance">
<span class="brand-text">CSAF</span>
</a>
<a href="/" class="{home_cls}">Dashboard</a>
<a href="/csaf" class="{csaf_cls}">CSAF</a>
<a href="/admin/import" class="{import_cls}">Import</a>
<a href="/admin/export" class="{export_cls}">Export</a>
<a href="/settings" class="{settings_cls}">Settings</a>
<a href="/info/about" class="{info_cls}">Info</a>
<span class="spacer"></span>
<form method="post" action="/settings/toggle-theme" style="margin:0">
<button type="submit" class="theme-toggle" title="Toggle theme">
{theme_icon}
</button>
</form>
</nav>
<main>
{content}
</main>
<footer>
CSAF · Pierre Gronau — ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne · Apache-2.0
</footer>
</body>
</html>"#,
title = title,
theme = theme,
content = content,
theme_icon = if theme == "dark" {
"☀ Light"
} else {
"☾ Dark"
},
home_cls = if active_key == "home" { "active" } else { "" },
csaf_cls = if active_key == "csaf" { "active" } else { "" },
import_cls = if active_key == "import" { "active" } else { "" },
export_cls = if active_key == "export" { "active" } else { "" },
settings_cls = if active_key == "settings" {
"active"
} else {
""
},
info_cls = if active_key == "info" { "active" } else { "" },
)
}
#[must_use]
pub fn tlp_color_class(label: &str) -> &'static str {
match label {
"CLEAR" => "tlp-clear",
"GREEN" => "tlp-green",
"AMBER" => "tlp-amber",
"AMBER+STRICT" => "tlp-amber-strict",
"RED" => "tlp-red",
_ => "tlp-unknown",
}
}
#[must_use]
pub fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wrap_page_light_theme_attribute() {
let html = wrap_page("Test", "light", Nav::Home, "<p>body</p>");
assert!(html.contains(r#"data-bs-theme="light""#));
assert!(html.contains("☾ Dark"));
}
#[test]
fn test_wrap_page_dark_theme_attribute() {
let html = wrap_page("Test", "dark", Nav::Home, "<p>body</p>");
assert!(html.contains(r#"data-bs-theme="dark""#));
assert!(html.contains("☀ Light"));
}
#[test]
fn test_wrap_page_invalid_theme_falls_back_to_light() {
let html = wrap_page("Test", "gibberish", Nav::Home, "");
assert!(html.contains(r#"data-bs-theme="light""#));
}
#[test]
fn test_wrap_page_highlights_active_nav() {
let html = wrap_page("Settings", "light", Nav::Settings, "");
assert!(html.contains(r#"href="/settings" class="active""#));
assert!(html.contains(r#"href="/" class="""#));
}
#[test]
fn test_wrap_page_uses_csaf_branding_not_csaf_crud() {
let html = wrap_page("Test", "light", Nav::Home, "");
assert!(html.contains(r#"class="brand-text">CSAF</span>"#));
assert!(!html.contains("CSAF CRUD"));
assert!(html.contains("Test — CSAF</title>"));
}
#[test]
fn test_wrap_page_includes_logo_image() {
let html = wrap_page("Test", "light", Nav::Home, "");
assert!(html.contains(r#"<img src="/static/img/logo.png""#));
assert!(html.contains("alt=\"ndaal"));
}
#[test]
fn test_layout_uses_burgundy_red_not_blue() {
let html = wrap_page("Test", "light", Nav::Home, "");
assert!(
html.contains("#8b1a1a"),
"Should use burgundy red from logo"
);
assert!(!html.contains("#1a237e"), "Should not contain old blue");
}
#[test]
fn test_html_escape_special_chars() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a & b"), "a & b");
assert_eq!(html_escape(r#"'"<>&"#), "'"<>&");
}
#[test]
fn test_tlp_color_class_whitelist() {
assert_eq!(tlp_color_class("CLEAR"), "tlp-clear");
assert_eq!(tlp_color_class("GREEN"), "tlp-green");
assert_eq!(tlp_color_class("AMBER"), "tlp-amber");
assert_eq!(tlp_color_class("AMBER+STRICT"), "tlp-amber-strict");
assert_eq!(tlp_color_class("RED"), "tlp-red");
}
#[test]
fn test_tlp_color_class_rejects_untrusted() {
for bad in [
"",
"clear",
"red\"><script>alert(1)</script>",
" CLEAR ",
"WHITE",
" ",
] {
let class = tlp_color_class(bad);
assert_eq!(class, "tlp-unknown", "bad={bad:?} -> {class}");
assert!(
class
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
);
}
}
#[test]
fn test_wrap_page_includes_tlp_color_css() {
let html = wrap_page("Test", "light", Nav::Home, "");
assert!(html.contains(".tlp-select.tlp-red"));
assert!(html.contains(".tlp-select.tlp-amber"));
assert!(html.contains(".tlp-select.tlp-green"));
assert!(html.contains(".tlp-select.tlp-clear"));
assert!(html.contains("DOMContentLoaded"));
assert!(html.contains(r#""AMBER+STRICT": "tlp-amber-strict""#));
}
}