#![allow(clippy::literal_string_with_formatting_args)]
use csaf_core::audit_export::{AuditExportOptions, export_audit_log};
use csaf_core::dump::dump_database;
use csaf_core::export::export_document;
use csaf_core::import::import_directory;
use http::{Response, StatusCode};
use crate::app_state::AppState;
use crate::i18n::{self, Lang};
use crate::router::{Body, form_checkbox, html_response, parse_form_body};
use crate::routes::layout::{Nav, PageContext, html_escape, wrap_page};
fn subst(template: &str, pairs: &[(&str, &str)]) -> String {
pairs
.iter()
.fold(template.to_owned(), |acc, (needle, value)| {
acc.replace(needle, value)
})
}
pub async fn import_page(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let content = format!(
r#"<h1>{heading}</h1>
<div class="card">
<p>{intro}</p>
<div class="flash success">
<strong>{import_dir_label}</strong> <code>{import_dir}</code>
</div>
<p>{place_hint}</p>
<form method="POST" action="/admin/import"
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='{importing}';">
<button type="submit" class="btn">{start_import}</button>
</form>
</div>"#,
heading = i18n::t(lang, "admin.import_heading"),
intro = i18n::t(lang, "admin.import_intro"),
import_dir_label = i18n::t(lang, "admin.import_directory_label"),
import_dir = html_escape(&settings.import_directory),
place_hint = i18n::t(lang, "admin.import_place_hint"),
importing = i18n::t(lang, "admin.importing_button"),
start_import = i18n::t(lang, "admin.start_import_button"),
);
html_response(
StatusCode::OK,
wrap_page(
i18n::t(lang, "nav.import"),
PageContext {
theme: &settings.theme,
lang,
},
Nav::Import,
&content,
),
)
}
pub async fn import_execute(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let result = import_directory(&settings.import_directory, state.csaf_storage());
match result {
Ok(import_result) => {
let _ = state.db_pool().with_conn(|conn| {
csaf_models::audit_log::record(
conn,
"import",
"bulk-import",
None,
Some(&format!(
"imported={}, skipped={}, errors={}",
import_result.imported,
import_result.skipped,
import_result.errors.len(),
)),
)
});
let mut error_section = String::new();
if !import_result.errors.is_empty() {
error_section.push_str(&format!(
r#"<div class="flash error"><strong>{}</strong><ul>"#,
i18n::t(lang, "admin.errors_label"),
));
for err in &import_result.errors {
error_section.push_str(&format!("<li>{}</li>", html_escape(err)));
}
error_section.push_str("</ul></div>");
}
let imported_str = import_result.imported.to_string();
let skipped_str = import_result.skipped.to_string();
let result_line = subst(
i18n::t(lang, "admin.import_result_line"),
&[
("{imported}", imported_str.as_str()),
("{skipped}", skipped_str.as_str()),
],
);
let title = i18n::t(lang, "admin.import_complete_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash success">
<strong>{results_label}</strong>
{result_line}
</div>
{error_section}
<a class="btn" href="/csaf">{view_docs}</a>
<a class="btn secondary" href="/admin/import" style="margin-left:0.5rem;">{again}</a>"#,
results_label = i18n::t(lang, "admin.results_label"),
view_docs = i18n::t(lang, "admin.view_documents_link"),
again = i18n::t(lang, "admin.import_again_link"),
);
html_response(
StatusCode::OK,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Import,
&content,
),
)
},
Err(err) => {
let title = i18n::t(lang, "admin.import_failed_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash error">{err}</div>
<a class="btn secondary" href="/admin/import">{back}</a>"#,
err = html_escape(&err.to_string()),
back = i18n::t(lang, "admin.back_link"),
);
html_response(
StatusCode::INTERNAL_SERVER_ERROR,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Import,
&content,
),
)
},
}
}
pub async fn export_page(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let doc_count = state.csaf_storage().count_documents().unwrap_or(0);
let content = render_export_content(&settings, doc_count, lang);
html_response(
StatusCode::OK,
wrap_page(
i18n::t(lang, "nav.export"),
PageContext {
theme: &settings.theme,
lang,
},
Nav::Export,
&content,
),
)
}
fn sidecar_status_block(settings: &csaf_models::settings::Settings, lang: Lang) -> String {
let flag = |enabled: bool| {
i18n::t(
lang,
if enabled {
"admin.enabled_label"
} else {
"admin.disabled_label"
},
)
};
format!(
r#"<strong>{sha256_label}</strong> {sha256}<br>
<strong>{sha512_label}</strong> {sha512}<br>
<strong>{sha3_label}</strong> {sha3}<br>
<strong>{blake3_label}</strong> {blake3}<br>
<strong>{shake256_label}</strong> {shake256}"#,
sha256_label = i18n::t(lang, "admin.sha256_sidecars_label"),
sha256 = flag(settings.sidecar_sha256),
sha512_label = i18n::t(lang, "admin.sha512_sidecars_label"),
sha512 = flag(settings.sidecar_sha512),
sha3_label = i18n::t(lang, "admin.sha3_512_sidecars_label"),
sha3 = flag(settings.sidecar_sha3_512),
blake3_label = i18n::t(lang, "admin.blake3_512_sidecars_label"),
blake3 = flag(settings.sidecar_blake3_512),
shake256_label = i18n::t(lang, "admin.shake256_512_sidecars_label"),
shake256 = flag(settings.sidecar_shake256_512),
)
}
fn render_export_content(
settings: &csaf_models::settings::Settings,
doc_count: usize,
lang: Lang,
) -> String {
let sidecars = sidecar_status_block(settings, lang);
format!(
r#"<h1>{export_heading}</h1>
<div class="card">
<p>{export_intro}</p>
<div class="flash success">
<strong>{export_dir_label}</strong> <code>{export_dir}</code><br>
<strong>{csaf_mode_label}</strong> <code>{mode}</code><br>
<strong>{documents_to_export_label}</strong> {doc_count}<br>
{sidecars}
</div>
<form method="POST" action="/admin/export"
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='{exporting}';">
<button type="submit" class="btn">{start_export}</button>
</form>
</div>
<div class="card">
<h2>{dump_heading}</h2>
<p>{dump_intro}</p>
<div class="flash success">
<strong>{dump_dir_label}</strong> <code>{dump_dir}</code><br>
{sidecars}
</div>
<form method="POST" action="/admin/dump"
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='{dumping}';">
<button type="submit" class="btn">{start_dump}</button>
</form>
</div>
<div class="card">
<h2>{audit_heading}</h2>
<p>{audit_intro}</p>
<div class="flash success">
<strong>{export_dir_label}</strong> <code>{export_dir}</code><br>
{sidecars}
</div>
<form method="POST" action="/admin/export-audit"
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='{exporting_audit}';">
<div style="margin-bottom:0.75rem;">
<label style="font-weight:normal;"><input type="checkbox" name="audit_markdown" checked> {fmt_md}</label>
<label style="font-weight:normal; margin-left:1rem;"><input type="checkbox" name="audit_csv" checked> {fmt_csv}</label>
<label style="font-weight:normal; margin-left:1rem;"><input type="checkbox" name="audit_json" checked> {fmt_json}</label>
<label style="font-weight:normal; margin-left:1rem;"><input type="checkbox" name="audit_sarif" checked> {fmt_sarif}</label>
</div>
<button type="submit" class="btn">{audit_heading}</button>
</form>
</div>"#,
export_heading = i18n::t(lang, "admin.export_heading"),
export_intro = i18n::t(lang, "admin.export_intro"),
export_dir_label = i18n::t(lang, "admin.export_directory_label"),
export_dir = html_escape(&settings.export_directory),
csaf_mode_label = i18n::t(lang, "admin.csaf_mode_label"),
mode = html_escape(&settings.csaf_mode),
documents_to_export_label = i18n::t(lang, "admin.documents_to_export_label"),
doc_count = doc_count,
exporting = i18n::t(lang, "admin.exporting_button"),
start_export = i18n::t(lang, "admin.start_export_button"),
dump_heading = i18n::t(lang, "admin.dump_heading"),
dump_intro = i18n::t(lang, "admin.dump_intro"),
dump_dir_label = i18n::t(lang, "admin.dump_directory_label"),
dump_dir = html_escape(&settings.dump_directory),
dumping = i18n::t(lang, "admin.dumping_button"),
start_dump = i18n::t(lang, "admin.start_dump_button"),
audit_heading = i18n::t(lang, "admin.audit_export_heading"),
audit_intro = i18n::t(lang, "admin.audit_export_intro"),
exporting_audit = i18n::t(lang, "admin.exporting_audit_button"),
fmt_md = i18n::t(lang, "admin.format_markdown_label"),
fmt_csv = i18n::t(lang, "admin.format_csv_label"),
fmt_json = i18n::t(lang, "admin.format_json_label"),
fmt_sarif = i18n::t(lang, "admin.format_sarif_label"),
)
}
pub async fn export_execute(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let metas = state
.csaf_storage()
.list_meta(10_000, 0)
.unwrap_or_default();
let mut exported: usize = 0;
let mut errors: Vec<String> = Vec::new();
for meta in &metas {
match state.csaf_storage().get_document(&meta.tracking_id) {
Ok(Some(doc)) => match export_document(&doc, &settings) {
Ok(_path) => {
exported += 1;
},
Err(err) => {
errors.push(format!("{}: {err}", meta.tracking_id));
},
},
Ok(None) => {
errors.push(format!(
"{}: document not found in storage",
meta.tracking_id
));
},
Err(err) => {
errors.push(format!("{}: {err}", meta.tracking_id));
},
}
}
let _ = state.db_pool().with_conn(|conn| {
csaf_models::audit_log::record(
conn,
"export",
"bulk-export",
None,
Some(&format!("exported={exported}, errors={}", errors.len())),
)
});
let mut error_section = String::new();
if !errors.is_empty() {
error_section.push_str(&format!(
r#"<div class="flash error"><strong>{}</strong><ul>"#,
i18n::t(lang, "admin.errors_label"),
));
for err in &errors {
error_section.push_str(&format!("<li>{}</li>", html_escape(err)));
}
error_section.push_str("</ul></div>");
}
let exported_str = exported.to_string();
let mode_escaped = html_escape(&settings.csaf_mode);
let export_dir_escaped = html_escape(&settings.export_directory);
let result_line = subst(
i18n::t(lang, "admin.export_result_line"),
&[
("{exported}", exported_str.as_str()),
("{mode}", mode_escaped.as_str()),
("{export_dir}", export_dir_escaped.as_str()),
],
);
let title = i18n::t(lang, "admin.export_complete_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash success">
<strong>{results_label}</strong>
{result_line}
</div>
{error_section}
<a class="btn" href="/csaf">{view_docs}</a>
<a class="btn secondary" href="/admin/export" style="margin-left:0.5rem;">{again}</a>"#,
results_label = i18n::t(lang, "admin.results_label"),
view_docs = i18n::t(lang, "admin.view_documents_link"),
again = i18n::t(lang, "admin.export_again_link"),
);
html_response(
StatusCode::OK,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Export,
&content,
),
)
}
pub async fn dump_execute(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let data_dir = state.config().data_dir.clone();
let result = dump_database(
&data_dir,
&settings.dump_directory,
state.csaf_storage(),
state.db_pool(),
&settings,
);
match result {
Ok(dump) => {
let _ = state.db_pool().with_conn(|conn| {
csaf_models::audit_log::record(
conn,
"dump",
"db-dump",
None,
Some(&format!(
"redb={} bytes, sqlite={} bytes, sidecars={}",
dump.redb_bytes,
dump.sqlite_bytes,
dump.sidecars.len()
)),
)
});
let mut sidecar_list = String::new();
for side in &dump.sidecars {
sidecar_list.push_str(&format!(
"<li><code>{}</code></li>",
html_escape(&side.display().to_string())
));
}
let dump_dir_escaped = html_escape(&settings.dump_directory);
let ts_escaped = html_escape(&dump.timestamp);
let result_line = subst(
i18n::t(lang, "admin.dump_result_line"),
&[
("{dump_dir}", dump_dir_escaped.as_str()),
("{ts}", ts_escaped.as_str()),
],
);
let title = i18n::t(lang, "admin.dump_complete_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash success">
<strong>{results_label}</strong>
{result_line}
</div>
<div class="card">
<table>
<tr><th>redb</th><td><code>{redb_path}</code> ({redb_bytes} {bytes})</td></tr>
<tr><th>sqlite</th><td><code>{sqlite_path}</code> ({sqlite_bytes} {bytes})</td></tr>
</table>
<h2>{sidecar_files_heading}</h2>
<ul>{sidecar_list}</ul>
</div>
<a class="btn" href="/admin/export">{back_to_export}</a>"#,
results_label = i18n::t(lang, "admin.results_label"),
redb_path = html_escape(&dump.redb_path.display().to_string()),
redb_bytes = dump.redb_bytes,
sqlite_path = html_escape(&dump.sqlite_path.display().to_string()),
sqlite_bytes = dump.sqlite_bytes,
bytes = i18n::t(lang, "admin.bytes_label"),
sidecar_files_heading = i18n::t(lang, "admin.sidecar_files_heading"),
back_to_export = i18n::t(lang, "admin.back_to_export_link"),
);
html_response(
StatusCode::OK,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Export,
&content,
),
)
},
Err(err) => {
let title = i18n::t(lang, "admin.dump_failed_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash error">
<strong>{error_label}</strong> {err}
</div>
<a class="btn secondary" href="/admin/export">{back_to_export}</a>"#,
error_label = i18n::t(lang, "admin.error_label"),
err = html_escape(&err.to_string()),
back_to_export = i18n::t(lang, "admin.back_to_export_link"),
);
html_response(
StatusCode::INTERNAL_SERVER_ERROR,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Export,
&content,
),
)
},
}
}
pub async fn audit_export_execute(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let opts = match parse_form_body(&parts) {
Ok(form) => AuditExportOptions {
markdown: form_checkbox(&form, "audit_markdown"),
csv: form_checkbox(&form, "audit_csv"),
json: form_checkbox(&form, "audit_json"),
sarif: form_checkbox(&form, "audit_sarif"),
},
Err(_) => AuditExportOptions::default(),
};
if !(opts.markdown || opts.csv || opts.json || opts.sarif) {
let title = i18n::t(lang, "admin.audit_export_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash error">
{message}
</div>
<a class="btn secondary" href="/admin/export">{back_to_export}</a>"#,
message = i18n::t(lang, "admin.audit_export_no_format_selected"),
back_to_export = i18n::t(lang, "admin.back_to_export_link"),
);
return html_response(
StatusCode::BAD_REQUEST,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Export,
&content,
),
);
}
let result = export_audit_log(
state.db_pool(),
&settings.export_directory,
&settings,
&opts,
);
match result {
Ok(res) => {
let _ = state.db_pool().with_conn(|conn| {
csaf_models::audit_log::record(
conn,
"export",
"audit-log",
None,
Some(&format!(
"rows={}, payloads={}, sidecars={}",
res.rows,
res.written.len(),
res.sidecars.len()
)),
)
});
let mut payload_list = String::new();
for p in &res.written {
payload_list.push_str(&format!(
"<li><code>{}</code></li>",
html_escape(&p.display().to_string())
));
}
let mut sidecar_list = String::new();
for s in &res.sidecars {
sidecar_list.push_str(&format!(
"<li><code>{}</code></li>",
html_escape(&s.display().to_string())
));
}
let rows_str = res.rows.to_string();
let payloads_str = res.written.len().to_string();
let sidecars_str = res.sidecars.len().to_string();
let ts_escaped = html_escape(&res.timestamp);
let result_line = subst(
i18n::t(lang, "admin.audit_export_result_line"),
&[
("{rows}", rows_str.as_str()),
("{payloads}", payloads_str.as_str()),
("{sidecars}", sidecars_str.as_str()),
("{ts}", ts_escaped.as_str()),
],
);
let title = i18n::t(lang, "admin.audit_export_complete_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash success">
<strong>{results_label}</strong>
{result_line}
</div>
<div class="card">
<h2>{payloads_heading}</h2>
<ul>{payload_list}</ul>
<h2>{sidecars_heading}</h2>
<ul>{sidecar_list}</ul>
</div>
<a class="btn" href="/admin/export">{back_to_export}</a>"#,
results_label = i18n::t(lang, "admin.results_label"),
payloads_heading = i18n::t(lang, "admin.payloads_heading"),
sidecars_heading = i18n::t(lang, "admin.sidecars_heading"),
back_to_export = i18n::t(lang, "admin.back_to_export_link"),
);
html_response(
StatusCode::OK,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Export,
&content,
),
)
},
Err(err) => {
let title = i18n::t(lang, "admin.audit_export_failed_title");
let content = format!(
r#"<h1>{title}</h1>
<div class="flash error">
<strong>{error_label}</strong> {err}
</div>
<a class="btn secondary" href="/admin/export">{back_to_export}</a>"#,
error_label = i18n::t(lang, "admin.error_label"),
err = html_escape(&err.to_string()),
back_to_export = i18n::t(lang, "admin.back_to_export_link"),
);
html_response(
StatusCode::INTERNAL_SERVER_ERROR,
wrap_page(
title,
PageContext {
theme: &settings.theme,
lang,
},
Nav::Export,
&content,
),
)
},
}
}
#[cfg(test)]
mod export_page_tests {
use csaf_models::settings::Settings;
use super::Lang;
use super::render_export_content;
#[test]
fn export_page_reports_every_sidecar_family() {
let settings = Settings::default();
let html = render_export_content(&settings, 0, Lang::En);
for family in [
"SHA-256 sidecars:",
"SHA-512 sidecars:",
"SHA3-512 sidecars:",
"BLAKE3-512 sidecars:",
"SHAKE256-512 sidecars:",
] {
assert!(
html.contains(family),
"export page does not report {family:?}"
);
}
}
#[test]
fn all_three_cards_report_the_newer_families() {
let settings = Settings::default();
let html = render_export_content(&settings, 0, Lang::En);
assert_eq!(
html.matches("BLAKE3-512 sidecars:").count(),
3,
"expected BLAKE3-512 on the export, dump and audit-log cards"
);
assert_eq!(
html.matches("SHAKE256-512 sidecars:").count(),
3,
"expected SHAKE256-512 on the export, dump and audit-log cards"
);
}
#[test]
fn a_disabled_family_is_still_listed() {
let settings = Settings {
sidecar_blake3_512: false,
sidecar_shake256_512: false,
..Settings::default()
};
let html = render_export_content(&settings, 0, Lang::En);
assert!(html.contains("BLAKE3-512 sidecars:"));
assert!(html.contains("SHAKE256-512 sidecars:"));
assert!(
html.contains("disabled"),
"a switched-off family must read as disabled, not vanish"
);
}
#[test]
fn a_non_english_lang_changes_the_rendered_labels() {
let settings = Settings::default();
let en = render_export_content(&settings, 0, Lang::En);
let de = render_export_content(&settings, 0, Lang::De);
assert!(en.contains("Export CSAF Documents"));
assert!(de.contains("CSAF-Dokumente exportieren"));
assert!(!de.contains("Export CSAF Documents"));
}
}