use http::{Response, StatusCode};
use crate::app_state::AppState;
use crate::router::{Body, html_response};
use crate::routes::layout::{Nav, html_escape, wrap_page};
const INFO_SUB_NAV: &str = r#"<div class="card" style="padding:0.75rem 1rem;">
<a href="/info/about">About</a> ·
<a href="/info/license">License</a> ·
<a href="/info/system">System Info</a> ·
<a href="/info/privacy">Privacy</a> ·
<a href="/info/security">Security</a> ·
<a href="/changelog">Changelog</a> ·
<a href="/readme">README</a> ·
<a href="/administrator">Administrator Guide</a> ·
<a href="/user">User Guide</a>
</div>"#;
fn render_info_page(title: &str, theme: &str, body: &str) -> Response<Body> {
let content = format!("{INFO_SUB_NAV}{body}");
html_response(StatusCode::OK, wrap_page(title, theme, Nav::Info, &content))
}
pub async fn about(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let body = r#"<h1>About CSAF</h1>
<div class="card">
<p><strong>CSAF</strong> is a web-based management tool for creating, reading,
updating, and deleting Common Security Advisory Framework (CSAF) documents
conforming to OASIS CSAF versions 2.0 and 2.1.</p>
<h2>Features</h2>
<ul>
<li>Full CRUD operations for CSAF security advisories, VEX, and informational advisories</li>
<li>Built-in validation against CSAF 2.0/2.1 schema rules</li>
<li>CVSS v3.1 and v4.0 scoring display</li>
<li>SHA-256 and SHA3-512 sidecar hash file generation</li>
<li>Bulk import/export with filesystem directory scanning</li>
<li>Audit trail for all document operations</li>
<li>HATEOAS-compliant REST API</li>
<li>Embedded storage (redb + SQLite) — no external database required</li>
<li>TLS 1.3 with automatic certificate generation</li>
</ul>
<h2>Standards</h2>
<ul>
<li><a href="https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html"
target="_blank" rel="noopener">OASIS CSAF 2.0</a></li>
<li><a href="https://docs.oasis-open.org/csaf/csaf/v2.1/csaf-v2.1.html"
target="_blank" rel="noopener">OASIS CSAF 2.1</a></li>
</ul>
<h2>Contact</h2>
<p>Developed by <strong>ndaal Gesellschaft für Sicherheit in der
Informationstechnik mbH & Co KG</strong>, Cologne, Germany.</p>
</div>"#;
render_info_page("About", &state.settings().theme, body)
}
pub async fn license(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let body = r#"<h1>License</h1>
<div class="card">
<p>CSAF is licensed under the <strong>Apache License, Version 2.0</strong>.</p>
<p>SPDX-License-Identifier: <code>Apache-2.0</code></p>
<p>Copyright (c) 2026 Pierre Gronau, ndaal in Cologne.</p>
<h2>Apache License 2.0 Summary</h2>
<ul>
<li>You may use, modify, and distribute this software freely.</li>
<li>You must include the copyright notice and license in any distribution.</li>
<li>The software is provided "AS IS", without warranties.</li>
<li>Contributors grant a patent license for their contributions.</li>
</ul>
<p>Full license text:
<a href="https://www.apache.org/licenses/LICENSE-2.0"
target="_blank" rel="noopener">https://www.apache.org/licenses/LICENSE-2.0</a>
</p>
</div>"#;
render_info_page("License", &state.settings().theme, body)
}
pub async fn system_info(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let mut sys = sysinfo::System::new_all();
sys.refresh_all();
let os_name = sysinfo::System::name().unwrap_or_else(|| "Unknown".to_owned());
let os_version = sysinfo::System::os_version().unwrap_or_else(|| "Unknown".to_owned());
let kernel_version = sysinfo::System::kernel_version().unwrap_or_else(|| "Unknown".to_owned());
let hostname = sysinfo::System::host_name().unwrap_or_else(|| "Unknown".to_owned());
let total_memory_mb = sys.total_memory() / 1_048_576;
let used_memory_mb = sys.used_memory() / 1_048_576;
let cpu_count = sys.cpus().len();
let doc_count = state.csaf_storage().count_documents().unwrap_or(0);
let storage_ok = state.csaf_storage().check_storage_up().unwrap_or(false);
let config = state.config();
let settings = state.settings();
let body = format!(
r#"<h1>System Information</h1>
<div class="card">
<h2>Host</h2>
<table>
<tr><th>Hostname</th><td>{hostname}</td></tr>
<tr><th>OS</th><td>{os_name} {os_version}</td></tr>
<tr><th>Kernel</th><td>{kernel_version}</td></tr>
<tr><th>CPUs</th><td>{cpu_count}</td></tr>
<tr><th>Memory</th><td>{used_memory_mb} MB / {total_memory_mb} MB</td></tr>
</table>
</div>
<div class="card">
<h2>Application</h2>
<table>
<tr><th>Version</th><td>{app_version}</td></tr>
<tr><th>Rust Version</th><td>{rust_version}</td></tr>
<tr><th>Listen Address</th><td>{listen}</td></tr>
<tr><th>Data Directory</th><td>{data_dir}</td></tr>
<tr><th>Storage Status</th><td>{storage_status}</td></tr>
<tr><th>Documents Stored</th><td>{doc_count}</td></tr>
<tr><th>CSAF Mode</th><td>{csaf_mode}</td></tr>
</table>
</div>"#,
hostname = html_escape(&hostname),
os_name = html_escape(&os_name),
os_version = html_escape(&os_version),
kernel_version = html_escape(&kernel_version),
app_version = env!("CARGO_PKG_VERSION"),
rust_version = html_escape(env!("CARGO_PKG_RUST_VERSION", "unknown")),
listen = html_escape(&config.listen_address()),
data_dir = html_escape(&config.data_dir.display().to_string()),
storage_status = if storage_ok { "Healthy" } else { "Degraded" },
csaf_mode = html_escape(&settings.csaf_mode),
);
render_info_page("System Info", &settings.theme, &body)
}
pub async fn privacy(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let body = r#"<h1>Privacy</h1>
<div class="card">
<h2>Data Processing</h2>
<p>CSAF is designed as a self-hosted application. All data is stored
locally on the server where the application is deployed.</p>
<h2>Data Collected</h2>
<ul>
<li><strong>CSAF documents</strong> — stored in the embedded redb database</li>
<li><strong>User accounts</strong> — login credentials (hashed) stored in SQLite</li>
<li><strong>Audit logs</strong> — timestamps and actions for document operations</li>
<li><strong>Session data</strong> — temporary session tokens for authentication</li>
</ul>
<h2>No External Communication</h2>
<p>The application does not send data to external services, analytics
platforms, or third-party APIs. All processing occurs locally.</p>
<h2>Data Retention</h2>
<p>Data is retained until explicitly deleted by the user or administrator.
Audit logs are stored indefinitely for compliance purposes.</p>
<h2>GDPR Compliance</h2>
<p>As a self-hosted tool, GDPR compliance responsibility lies with the
deploying organisation. The application supports data export and deletion
to facilitate compliance.</p>
</div>"#;
render_info_page("Privacy", &state.settings().theme, body)
}
pub async fn security(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let body = r#"<h1>Security</h1>
<div class="card">
<h2>Transport Security</h2>
<ul>
<li>TLS 1.3 enforced via Rustls (no OpenSSL dependency)</li>
<li>RSA ciphers excluded; only elliptic-curve cryptography (ECC) used</li>
<li>Automatic self-signed certificate generation (45-day validity)</li>
<li>HTTP/1.1 + HTTP/2 auto-negotiated via ALPN over TCP</li>
<li>HTTP/3 served over QUIC/UDP</li>
</ul>
<h2>Authentication</h2>
<ul>
<li>Password hashing using Argon2id (RFC 9106)</li>
<li>API key authentication for programmatic access</li>
<li>Session-based authentication for the web UI</li>
</ul>
<h2>Data Integrity</h2>
<ul>
<li>SHA-256 and SHA3-512 sidecar hash files for exported documents</li>
<li>CSAF document validation on every create, update, import, and export</li>
<li>Audit trail for all document lifecycle operations</li>
</ul>
<h2>Input Validation</h2>
<ul>
<li>All CSAF documents validated against structural and semantic rules</li>
<li>CVSS score range validation (0.0 to 10.0)</li>
<li>Product ID cross-reference validation</li>
<li>HTML output is escaped to prevent XSS</li>
<li>RFC 9457 Problem Details for all API errors</li>
</ul>
<h2>Reporting Vulnerabilities</h2>
<p>If you discover a security vulnerability in CSAF, please report it
responsibly by contacting the development team at ndaal in Cologne.</p>
</div>"#;
render_info_page("Security", &state.settings().theme, body)
}
const CHANGELOG_MD: &str = include_str!("../../embedded_docs/CHANGELOG.md");
const README_MD: &str = include_str!("../../embedded_docs/README.md");
const ADMINISTRATOR_GUIDE_MD: &str = include_str!("../../embedded_docs/Administrator_Guide.md");
const USER_GUIDE_MD: &str = include_str!("../../embedded_docs/User_Guide.md");
fn doc_page_html(title: &str, theme: &str, heading: &str, markdown: &str) -> String {
let body = format!(
r#"<h1>{heading}</h1>
<div class="card">
<pre style="white-space: pre-wrap; margin: 0;">{content}</pre>
</div>"#,
heading = html_escape(heading),
content = html_escape(markdown),
);
wrap_page(title, theme, Nav::Info, &format!("{INFO_SUB_NAV}{body}"))
}
fn render_doc_page(title: &str, theme: &str, heading: &str, markdown: &str) -> Response<Body> {
html_response(
StatusCode::OK,
doc_page_html(title, theme, heading, markdown),
)
}
pub async fn changelog(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
render_doc_page(
"Changelog",
&state.settings().theme,
"Changelog",
CHANGELOG_MD,
)
}
pub async fn readme(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
render_doc_page("README", &state.settings().theme, "README", README_MD)
}
pub async fn administrator_guide(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
render_doc_page(
"Administrator Guide",
&state.settings().theme,
"Administrator Guide",
ADMINISTRATOR_GUIDE_MD,
)
}
pub async fn user_guide(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
render_doc_page(
"User Guide",
&state.settings().theme,
"User Guide",
USER_GUIDE_MD,
)
}
#[cfg(test)]
mod doc_page_tests {
use super::*;
fn on_disk(rel: &str) -> String {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../")
.join(rel);
std::fs::read_to_string(root).expect("read on-disk doc file")
}
#[test]
fn changelog_embed_matches_on_disk_file() {
assert_eq!(CHANGELOG_MD, on_disk("CHANGELOG.md"));
}
#[test]
fn readme_embed_matches_on_disk_file() {
assert_eq!(README_MD, on_disk("README.md"));
}
#[test]
fn administrator_guide_embed_matches_on_disk_file() {
assert_eq!(
ADMINISTRATOR_GUIDE_MD,
on_disk("documentation/Administrator_Guide.md")
);
}
#[test]
fn user_guide_embed_matches_on_disk_file() {
assert_eq!(USER_GUIDE_MD, on_disk("documentation/User_Guide.md"));
}
#[test]
fn all_four_embeds_are_non_trivial() {
for (name, content) in [
("CHANGELOG_MD", CHANGELOG_MD),
("README_MD", README_MD),
("ADMINISTRATOR_GUIDE_MD", ADMINISTRATOR_GUIDE_MD),
("USER_GUIDE_MD", USER_GUIDE_MD),
] {
assert!(
content.len() > 200,
"{name} is suspiciously short ({} bytes) -- embed likely broken",
content.len()
);
}
}
#[test]
fn changelog_page_renders_full_content_without_truncation() {
let html = doc_page_html("Changelog", "light", "Changelog", CHANGELOG_MD);
assert!(html.contains("<h1>Changelog</h1>"));
let first_line = CHANGELOG_MD.lines().next().unwrap_or_default();
let last_line = CHANGELOG_MD.lines().last().unwrap_or_default();
assert!(html.contains(&html_escape(first_line)));
if !last_line.trim().is_empty() {
assert!(html.contains(&html_escape(last_line)));
}
}
#[test]
fn readme_page_renders_ok() {
let html = doc_page_html("README", "light", "README", README_MD);
assert!(html.contains("<h1>README</h1>"));
}
#[test]
fn administrator_guide_page_renders_ok() {
let html = doc_page_html(
"Administrator Guide",
"light",
"Administrator Guide",
ADMINISTRATOR_GUIDE_MD,
);
assert!(html.contains("<h1>Administrator Guide</h1>"));
}
#[test]
fn user_guide_page_renders_ok() {
let html = doc_page_html("User Guide", "light", "User Guide", USER_GUIDE_MD);
assert!(html.contains("<h1>User Guide</h1>"));
}
#[test]
fn doc_page_html_escapes_markdown_source() {
let payload = "<script>alert(1)</script>";
let html = doc_page_html("Test", "light", "Test", payload);
assert!(!html.contains(payload));
assert!(html.contains("<script>alert(1)</script>"));
}
#[test]
fn info_dropdown_links_all_four_doc_pages() {
let html = wrap_page("Test", "light", Nav::Info, "");
for href in ["/changelog", "/readme", "/administrator", "/user"] {
assert!(
html.contains(&format!(r#"href="{href}""#)),
"navbar Info dropdown missing link to {href}"
);
}
assert!(html.contains("dropdown-divider"));
}
}