use http::{Response, StatusCode};
use crate::app_state::AppState;
use crate::i18n::{self, Lang, t};
use crate::router::{Body, html_response};
use crate::routes::layout::{Nav, PageContext, html_escape, wrap_page};
fn info_sub_nav(lang: Lang) -> String {
format!(
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}</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}</a> ·
<a href="/user">{user}</a>
</div>"#,
about = t(lang, "info.subnav_about"),
license = t(lang, "info.subnav_license"),
system = t(lang, "info.subnav_system"),
privacy = t(lang, "info.subnav_privacy"),
security = t(lang, "info.subnav_security"),
changelog = t(lang, "info.subnav_changelog"),
readme = t(lang, "info.subnav_readme"),
administrator = t(lang, "info.subnav_administrator"),
user = t(lang, "info.subnav_user"),
)
}
fn render_info_page(title: &str, ctx: PageContext<'_>, body: &str) -> Response<Body> {
let content = format!("{}{body}", info_sub_nav(ctx.lang));
html_response(StatusCode::OK, wrap_page(title, ctx, Nav::Info, &content))
}
pub async fn about(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.about_heading");
let body = format!(
r#"<h1>{heading}</h1>
<div class="card">
<p>{intro}</p>
<p>{version_label} <strong>{app_version}</strong></p>
<h2>{features_heading}</h2>
<ul>
{features_list}
</ul>
<h2>{standards_heading}</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>{source_heading}</h2>
<p>{source_intro}</p>
<ul>
<li>{repository_label}
<a href="https://gitlab.com/vPierre/ndaal_public_csaf_crud"
target="_blank" rel="noopener">https://gitlab.com/vPierre/ndaal_public_csaf_crud</a></li>
<li>{releases_label}
<a href="https://gitlab.com/vPierre/ndaal_public_csaf_crud/-/releases"
target="_blank" rel="noopener">https://gitlab.com/vPierre/ndaal_public_csaf_crud/-/releases</a></li>
<li>{cratesio_label}
<a href="https://crates.io/crates/csaf-crud" target="_blank"
rel="noopener">csaf-crud</a> ·
<a href="https://crates.io/crates/ndaal-csaf-cli" target="_blank"
rel="noopener">ndaal-csaf-cli</a> ·
<a href="https://crates.io/crates/csaf-core" target="_blank"
rel="noopener">csaf-core</a> ·
<a href="https://crates.io/crates/csaf-models" target="_blank"
rel="noopener">csaf-models</a></li>
</ul>
<p>{sha_note}</p>
<h2>{contact_heading}</h2>
<p>{contact_text}</p>
</div>"#,
heading = heading,
intro = t(lang, "info.about_intro"),
version_label = t(lang, "info.about_version_label"),
app_version = env!("CARGO_PKG_VERSION"),
features_heading = t(lang, "info.about_features_heading"),
features_list = t(lang, "info.about_features_list"),
standards_heading = t(lang, "info.about_standards_heading"),
source_heading = t(lang, "info.about_source_heading"),
source_intro = t(lang, "info.about_source_intro"),
repository_label = t(lang, "info.about_repository_label"),
releases_label = t(lang, "info.about_releases_label"),
cratesio_label = t(lang, "info.about_cratesio_label"),
sha_note = t(lang, "info.about_sha_note"),
contact_heading = t(lang, "info.about_contact_heading"),
contact_text = t(lang, "info.about_contact_text"),
);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_info_page(heading, ctx, &body)
}
pub async fn license(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.license_heading");
let body = format!(
r#"<h1>{heading}</h1>
<div class="card">
<p>{intro}</p>
<p>{spdx_label} <code>Apache-2.0</code></p>
<p>Copyright (c) 2026 Pierre Gronau, ndaal in Cologne.</p>
<h2>{summary_heading}</h2>
<ul>
{summary_list}
</ul>
<p>{full_text_label}
<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>"#,
heading = heading,
intro = t(lang, "info.license_intro"),
spdx_label = t(lang, "info.license_spdx_label"),
summary_heading = t(lang, "info.license_summary_heading"),
summary_list = t(lang, "info.license_summary_list"),
full_text_label = t(lang, "info.license_full_text_label"),
);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_info_page(heading, ctx, &body)
}
pub async fn system_info(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
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 heading = t(lang, "info.system_heading");
let body = format!(
r#"<h1>{heading}</h1>
<div class="card">
<h2>{host_heading}</h2>
<table>
<tr><th>{hostname_label}</th><td>{hostname}</td></tr>
<tr><th>{os_label}</th><td>{os_name} {os_version}</td></tr>
<tr><th>{kernel_label}</th><td>{kernel_version}</td></tr>
<tr><th>{cpus_label}</th><td>{cpu_count}</td></tr>
<tr><th>{memory_label}</th><td>{used_memory_mb} MB / {total_memory_mb} MB</td></tr>
</table>
</div>
<div class="card">
<h2>{application_heading}</h2>
<table>
<tr><th>{version_label}</th><td>{app_version}</td></tr>
<tr><th>{rust_version_label}</th><td>{rust_version}</td></tr>
<tr><th>{listen_address_label}</th><td>{listen}</td></tr>
<tr><th>{data_directory_label}</th><td>{data_dir}</td></tr>
<tr><th>{storage_status_label}</th><td>{storage_status}</td></tr>
<tr><th>{documents_stored_label}</th><td>{doc_count}</td></tr>
<tr><th>{csaf_mode_label}</th><td>{csaf_mode}</td></tr>
</table>
</div>"#,
heading = heading,
host_heading = t(lang, "info.system_host_heading"),
hostname_label = t(lang, "info.system_hostname_label"),
hostname = html_escape(&hostname),
os_label = t(lang, "info.system_os_label"),
os_name = html_escape(&os_name),
os_version = html_escape(&os_version),
kernel_label = t(lang, "info.system_kernel_label"),
kernel_version = html_escape(&kernel_version),
cpus_label = t(lang, "info.system_cpus_label"),
cpu_count = cpu_count,
memory_label = t(lang, "info.system_memory_label"),
used_memory_mb = used_memory_mb,
total_memory_mb = total_memory_mb,
application_heading = t(lang, "info.system_application_heading"),
version_label = t(lang, "info.system_version_label"),
app_version = env!("CARGO_PKG_VERSION"),
rust_version_label = t(lang, "info.system_rust_version_label"),
rust_version = html_escape(env!("CARGO_PKG_RUST_VERSION", "unknown")),
listen_address_label = t(lang, "info.system_listen_address_label"),
listen = html_escape(&config.listen_address()),
data_directory_label = t(lang, "info.system_data_directory_label"),
data_dir = html_escape(&config.data_dir.display().to_string()),
storage_status_label = t(lang, "info.system_storage_status_label"),
storage_status = if storage_ok {
t(lang, "info.system_status_healthy")
} else {
t(lang, "info.system_status_degraded")
},
documents_stored_label = t(lang, "info.system_documents_stored_label"),
doc_count = doc_count,
csaf_mode_label = t(lang, "info.system_csaf_mode_label"),
csaf_mode = html_escape(&settings.csaf_mode),
);
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_info_page(heading, ctx, &body)
}
pub async fn privacy(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.privacy_heading");
let body = format!(
r#"<h1>{heading}</h1>
<div class="card">
<h2>{data_processing_heading}</h2>
<p>{data_processing_text}</p>
<h2>{data_collected_heading}</h2>
<ul>
{data_collected_list}
</ul>
<h2>{no_external_heading}</h2>
<p>{no_external_text}</p>
<h2>{retention_heading}</h2>
<p>{retention_text}</p>
<h2>{gdpr_heading}</h2>
<p>{gdpr_text}</p>
</div>"#,
heading = heading,
data_processing_heading = t(lang, "info.privacy_data_processing_heading"),
data_processing_text = t(lang, "info.privacy_data_processing_text"),
data_collected_heading = t(lang, "info.privacy_data_collected_heading"),
data_collected_list = t(lang, "info.privacy_data_collected_list"),
no_external_heading = t(lang, "info.privacy_no_external_heading"),
no_external_text = t(lang, "info.privacy_no_external_text"),
retention_heading = t(lang, "info.privacy_retention_heading"),
retention_text = t(lang, "info.privacy_retention_text"),
gdpr_heading = t(lang, "info.privacy_gdpr_heading"),
gdpr_text = t(lang, "info.privacy_gdpr_text"),
);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_info_page(heading, ctx, &body)
}
pub async fn security(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.security_heading");
let body = format!(
r#"<h1>{heading}</h1>
<div class="card">
<h2>{transport_heading}</h2>
<ul>
{transport_list}
</ul>
<h2>{auth_heading}</h2>
<ul>
{auth_list}
</ul>
<h2>{integrity_heading}</h2>
<ul>
{integrity_list}
</ul>
<h2>{input_heading}</h2>
<ul>
{input_list}
</ul>
<h2>{reporting_heading}</h2>
<p>{reporting_text}</p>
</div>"#,
heading = heading,
transport_heading = t(lang, "info.security_transport_heading"),
transport_list = t(lang, "info.security_transport_list"),
auth_heading = t(lang, "info.security_auth_heading"),
auth_list = t(lang, "info.security_auth_list"),
integrity_heading = t(lang, "info.security_integrity_heading"),
integrity_list = t(lang, "info.security_integrity_list"),
input_heading = t(lang, "info.security_input_heading"),
input_list = t(lang, "info.security_input_list"),
reporting_heading = t(lang, "info.security_reporting_heading"),
reporting_text = t(lang, "info.security_reporting_text"),
);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_info_page(heading, ctx, &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, ctx: PageContext<'_>, 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,
ctx,
Nav::Info,
&format!("{}{body}", info_sub_nav(ctx.lang)),
)
}
fn render_doc_page(
title: &str,
ctx: PageContext<'_>,
heading: &str,
markdown: &str,
) -> Response<Body> {
html_response(StatusCode::OK, doc_page_html(title, ctx, heading, markdown))
}
pub async fn changelog(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.changelog_heading");
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_doc_page(heading, ctx, heading, CHANGELOG_MD)
}
pub async fn readme(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.readme_heading");
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_doc_page(heading, ctx, heading, README_MD)
}
pub async fn administrator_guide(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.administrator_heading");
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_doc_page(heading, ctx, heading, ADMINISTRATOR_GUIDE_MD)
}
pub async fn user_guide(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let heading = t(lang, "info.user_heading");
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
render_doc_page(heading, ctx, heading, USER_GUIDE_MD)
}
#[cfg(test)]
mod doc_page_tests {
use super::*;
fn ctx(theme: &str) -> PageContext<'_> {
PageContext {
theme,
lang: Lang::En,
}
}
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", ctx("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", ctx("light"), "README", README_MD);
assert!(html.contains("<h1>README</h1>"));
}
#[test]
fn administrator_guide_page_renders_ok() {
let html = doc_page_html(
"Administrator Guide",
ctx("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", ctx("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", ctx("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", ctx("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"));
}
}