use crate::i18n::{Lang, t};
#[derive(Debug, Clone, Copy)]
pub struct PageContext<'a> {
pub theme: &'a str,
pub lang: Lang,
}
#[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",
}
}
}
const PAGE_STYLE: &str = r#" @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;
}
.skip-link {
position: absolute; left: -9999px; top: 0; z-index: 100;
background: var(--surface); color: var(--text);
padding: 0.6rem 1rem; border-radius: 0 0 4px 0;
}
.skip-link:focus { left: 0; }
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); }
nav.primary details.nav-dropdown { position: relative; }
nav.primary details.nav-dropdown summary {
color: var(--primary-contrast); text-decoration: none;
font-size: 0.9rem; padding: 0.35rem 0.6rem; border-radius: 4px;
cursor: pointer; list-style: none;
}
nav.primary details.nav-dropdown summary::-webkit-details-marker { display: none; }
nav.primary details.nav-dropdown summary:hover { background: rgba(255,255,255,0.14); }
nav.primary details.nav-dropdown[open] summary,
nav.primary details.nav-dropdown summary.active {
background: rgba(255,255,255,0.22); font-weight: 600;
}
nav.primary details.nav-dropdown .dropdown-menu {
position: absolute; top: 100%; left: 0; margin-top: 0.25rem;
background: var(--surface); color: var(--text);
border: 1px solid rgba(0,0,0,0.15); border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.25); min-width: 190px;
padding: 0.4rem 0; z-index: 20;
}
nav.primary details.nav-dropdown .dropdown-menu a {
display: block; color: inherit; padding: 0.4rem 0.9rem;
border-radius: 0; font-size: 0.9rem;
}
nav.primary details.nav-dropdown .dropdown-menu a:hover { background: rgba(128,128,128,0.15); }
nav.primary details.nav-dropdown .dropdown-divider {
border: none; border-top: 1px solid rgba(128,128,128,0.3); margin: 0.35rem 0;
}
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; }"#;
const PAGE_SCRIPT: &str = r#" // 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();
});
});"#;
fn language_switcher(lang: Lang) -> String {
use std::fmt::Write as _;
let items = Lang::all()
.into_iter()
.fold(String::new(), |mut acc, choice| {
let cls = if choice == lang { "active" } else { "" };
let current = if choice == lang {
r#" aria-current="true""#
} else {
""
};
let _ = writeln!(
acc,
r#" <a href="/lang/{code}" class="{cls}"{current}>{endonym}</a>"#,
code = choice.code(),
endonym = choice.endonym(),
);
acc
});
format!(
r#" <details class="nav-dropdown">
<summary>{label}</summary>
<div class="dropdown-menu">
{items} </div>
</details>
"#,
label = t(lang, "nav.language"),
)
}
fn nav_links(lang: Lang, active_key: &str) -> String {
let cls = |key: &str| if active_key == key { "active" } else { "" };
format!(
r#" <a href="/" class="{home_cls}">{nav_dashboard}</a>
<a href="/csaf" class="{csaf_cls}">{nav_csaf}</a>
<a href="/admin/import" class="{import_cls}">{nav_import}</a>
<a href="/admin/export" class="{export_cls}">{nav_export}</a>
<a href="/settings" class="{settings_cls}">{nav_settings}</a>
"#,
home_cls = cls("home"),
csaf_cls = cls("csaf"),
import_cls = cls("import"),
export_cls = cls("export"),
settings_cls = cls("settings"),
nav_dashboard = t(lang, "nav.dashboard"),
nav_csaf = t(lang, "nav.csaf"),
nav_import = t(lang, "nav.import"),
nav_export = t(lang, "nav.export"),
nav_settings = t(lang, "nav.settings"),
)
}
fn theme_toggle_button(lang: Lang, theme: &str) -> String {
let icon_label = if theme == "dark" {
format!("โ {}", t(lang, "nav.theme_light"))
} else {
format!("โพ {}", t(lang, "nav.theme_dark"))
};
format!(
r#" <form method="post" action="/settings/toggle-theme" style="margin:0">
<button type="submit" class="theme-toggle" title="{title}">
{icon_label}
</button>
</form>
"#,
title = t(lang, "nav.theme_toggle"),
)
}
fn info_menu(lang: Lang, active_key: &str) -> String {
let info_cls = if active_key == "info" { "active" } else { "" };
format!(
r#" <details class="nav-dropdown">
<summary class="{info_cls}">{nav_info}</summary>
<div class="dropdown-menu">
<a href="/info/about">{info_about}</a>
<a href="/info/license">{info_license}</a>
<a href="/info/system">{info_system}</a>
<a href="/info/privacy">{info_privacy}</a>
<a href="/info/security">{info_security}</a>
<hr class="dropdown-divider">
<a href="/changelog">{info_changelog}</a>
<a href="/readme">{info_readme}</a>
<a href="/administrator">{info_administrator}</a>
<a href="/user">{info_user}</a>
</div>
</details>
"#,
nav_info = t(lang, "nav.info"),
info_about = t(lang, "nav.info_about"),
info_license = t(lang, "nav.info_license"),
info_system = t(lang, "nav.info_system"),
info_privacy = t(lang, "nav.info_privacy"),
info_security = t(lang, "nav.info_security"),
info_changelog = t(lang, "nav.info_changelog"),
info_readme = t(lang, "nav.info_readme"),
info_administrator = t(lang, "nav.info_administrator"),
info_user = t(lang, "nav.info_user"),
)
}
#[must_use]
pub fn wrap_page(title: &str, ctx: PageContext<'_>, active: Nav, content: &str) -> String {
let theme = if ctx.theme == "dark" { "dark" } else { "light" };
let lang = ctx.lang;
let active_key = active.as_str();
let nav_links = nav_links(lang, active_key);
let theme_toggle = theme_toggle_button(lang, theme);
let lang_switcher = language_switcher(lang);
let info_menu = info_menu(lang, active_key);
format!(
r##"<!DOCTYPE html>
<html lang="{lang_code}" data-bs-theme="{theme}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} โ CSAF</title>
<style>
{PAGE_STYLE}
</style>
<script>
{PAGE_SCRIPT}
</script>
</head>
<body>
<a href="#main-content" class="skip-link">{skip_main}</a>
<nav class="primary">
<a href="/" class="brand" aria-label="{brand_label}">
<img src="/static/img/logo.png" alt="{logo_alt}">
<span class="brand-text">CSAF</span>
</a>
{nav_links} <span class="spacer"></span>
{theme_toggle}{lang_switcher}{info_menu}</nav>
<main id="main-content">
{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,
lang_code = lang.code(),
content = content,
skip_main = t(lang, "nav.skip_main"),
brand_label = t(lang, "nav.brand_label"),
logo_alt = t(lang, "nav.logo_alt"),
)
}
#[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::*;
fn ctx(theme: &str) -> PageContext<'_> {
PageContext {
theme,
lang: Lang::En,
}
}
#[test]
fn test_wrap_page_light_theme_attribute() {
let html = wrap_page("Test", ctx("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", ctx("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", ctx("gibberish"), Nav::Home, "");
assert!(html.contains(r#"data-bs-theme="light""#));
}
#[test]
fn test_wrap_page_highlights_active_nav() {
let html = wrap_page("Settings", ctx("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", ctx("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", ctx("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", ctx("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", ctx("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""#));
}
#[test]
fn test_wrap_page_sets_html_lang_from_context() {
let html = wrap_page(
"Test",
PageContext {
theme: "light",
lang: Lang::De,
},
Nav::Home,
"",
);
assert!(html.contains(r#"<html lang="de""#));
assert!(html.contains("Einstellungen")); }
#[test]
fn test_wrap_page_header_cluster_order_is_theme_language_info() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
let theme_pos = html.find("theme-toggle").expect("theme control present");
let lang_pos = html
.find(r#"href="/lang/en""#)
.expect("language control present");
let info_pos = html
.find(r#"href="/info/about""#)
.expect("info control present");
assert!(theme_pos < lang_pos, "theme must precede language");
assert!(lang_pos < info_pos, "language must precede info");
}
#[test]
fn test_wrap_page_language_switcher_lists_all_five_with_current_marked() {
let html = wrap_page(
"Test",
PageContext {
theme: "light",
lang: Lang::Fr,
},
Nav::Home,
"",
);
for code in ["en", "de", "fr", "es", "it"] {
assert!(
html.contains(&format!(r#"href="/lang/{code}""#)),
"missing switcher link for {code}"
);
}
assert!(html.contains(r#"href="/lang/fr" class="active" aria-current="true""#));
assert!(!html.contains(r#"href="/lang/en" class="active""#));
}
#[test]
fn test_wrap_page_includes_skip_link_targeting_main() {
let html = wrap_page("Test", ctx("light"), Nav::Home, "");
assert!(html.contains(r##"<a href="#main-content" class="skip-link">"##));
assert!(html.contains(r#"<main id="main-content">"#));
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_html_escape_never_leaves_unescaped_markup_chars(s in ".*") {
let escaped = html_escape(&s);
prop_assert!(!escaped.contains('<'));
prop_assert!(!escaped.contains('>'));
prop_assert!(!escaped.contains('"'));
prop_assert!(!escaped.contains('\''));
}
}
}