use std::path::Path;
use http::{Response, StatusCode};
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 crate::app_state::AppState;
use crate::router::{Body, form_checkbox, html_response, parse_form_body};
use crate::routes::layout::{Nav, html_escape, wrap_page};
pub async fn import_page(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let content = format!(
r#"<h1>Import CSAF Documents</h1>
<div class="card">
<p>Import CSAF JSON documents from the configured import directory.
Every file is <strong>validated</strong> before being stored. Files whose
CSAF schema or semantic rules fail hard validation are skipped with an
error entry in the result.</p>
<div class="flash success">
<strong>Import directory:</strong> <code>{import_dir}</code>
</div>
<p>Place <code>*.json</code> CSAF files in the import directory (subdirectories are scanned
recursively). Files named <code>provider-metadata.json</code> are skipped automatically.</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>"#,
import_dir = html_escape(&settings.import_directory),
);
html_response(
StatusCode::OK,
wrap_page("Import", &settings.theme, Nav::Import, &content),
)
}
pub async fn import_execute(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let import_dir = Path::new(&settings.import_directory);
let result = import_directory(import_dir, 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(r#"<div class="flash error"><strong>Errors:</strong><ul>"#);
for err in &import_result.errors {
error_section.push_str(&format!("<li>{}</li>", html_escape(err)));
}
error_section.push_str("</ul></div>");
}
let content = format!(
r#"<h1>Import Complete</h1>
<div class="flash success">
<strong>Results:</strong>
{imported} document(s) imported, {skipped} skipped.
</div>
{error_section}
<a class="btn" href="/csaf">View Documents</a>
<a class="btn secondary" href="/admin/import" style="margin-left:0.5rem;">Import Again</a>"#,
imported = import_result.imported,
skipped = import_result.skipped,
);
html_response(
StatusCode::OK,
wrap_page("Import Complete", &settings.theme, Nav::Import, &content),
)
},
Err(err) => {
let content = format!(
r#"<h1>Import Failed</h1>
<div class="flash error">{err}</div>
<a class="btn secondary" href="/admin/import">Back</a>"#,
err = html_escape(&err.to_string()),
);
html_response(
StatusCode::INTERNAL_SERVER_ERROR,
wrap_page("Import Failed", &settings.theme, Nav::Import, &content),
)
},
}
}
pub async fn export_page(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let doc_count = state.csaf_storage().count_documents().unwrap_or(0);
let content = format!(
r#"<h1>Export CSAF Documents</h1>
<div class="card">
<p>Export all stored CSAF documents to the filesystem with sidecar hash files.
Each document is <strong>validated</strong> and its <code>csaf_version</code> is
rewritten to match the currently active CSAF mode.</p>
<div class="flash success">
<strong>Export directory:</strong> <code>{export_dir}</code><br>
<strong>CSAF mode (written into files):</strong> <code>{mode}</code><br>
<strong>Documents to export:</strong> {doc_count}<br>
<strong>SHA-256 sidecars:</strong> {sha256}<br>
<strong>SHA-512 sidecars:</strong> {sha512}<br>
<strong>SHA3-512 sidecars:</strong> {sha3}
</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 Database</h2>
<p>Snapshot the live <code>csaf.redb</code> and <code>csaf.sqlite</code>
databases into the configured <strong>Dump Directory</strong> with matching
SHA-256, SHA-512, and SHA3-512 hash sidecars. The sqlite snapshot uses the
online-backup API so writes in flight are not lost, and the redb copy is
pinned by an MVCC read transaction for the duration of the copy.</p>
<div class="flash success">
<strong>Dump directory:</strong> <code>{dump_dir}</code><br>
<strong>SHA-256 sidecars:</strong> {sha256}<br>
<strong>SHA-512 sidecars:</strong> {sha512}<br>
<strong>SHA3-512 sidecars:</strong> {sha3}
</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>Export Audit Log</h2>
<p>Export every row of the <code>audit_log</code> table (CRUD, settings
changes, import / export events) in four formats, each with matching
hash sidecars. Outputs land in the <strong>Export Directory</strong>
under an ISO-8601 timestamp stem:
<code>audit-YYYY-MM-DDTHH-MM-SSZ.{{md,csv,json,sarif}}</code>.
SARIF 2.1.0 output is self-validated before write.</p>
<div class="flash success">
<strong>Export directory:</strong> <code>{export_dir}</code><br>
<strong>SHA-256 sidecars:</strong> {sha256}<br>
<strong>SHA-512 sidecars:</strong> {sha512}<br>
<strong>SHA3-512 sidecars:</strong> {sha3}
</div>
<form method="POST" action="/admin/export-audit"
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='Exporting audit log...';">
<div style="margin-bottom:0.75rem;">
<label style="font-weight:normal;"><input type="checkbox" name="audit_markdown" checked> Markdown (<code>.md</code>)</label>
<label style="font-weight:normal; margin-left:1rem;"><input type="checkbox" name="audit_csv" checked> CSV (<code>.csv</code>)</label>
<label style="font-weight:normal; margin-left:1rem;"><input type="checkbox" name="audit_json" checked> JSON (<code>.json</code>)</label>
<label style="font-weight:normal; margin-left:1rem;"><input type="checkbox" name="audit_sarif" checked> SARIF 2.1 (<code>.sarif</code>)</label>
</div>
<button type="submit" class="btn">Export Audit Log</button>
</form>
</div>"#,
export_dir = html_escape(&settings.export_directory),
dump_dir = html_escape(&settings.dump_directory),
mode = html_escape(&settings.csaf_mode),
sha256 = if settings.sidecar_sha256 {
"enabled"
} else {
"disabled"
},
sha512 = if settings.sidecar_sha512 {
"enabled"
} else {
"disabled"
},
sha3 = if settings.sidecar_sha3_512 {
"enabled"
} else {
"disabled"
},
);
html_response(
StatusCode::OK,
wrap_page("Export", &settings.theme, Nav::Export, &content),
)
}
pub async fn export_execute(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
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(r#"<div class="flash error"><strong>Errors:</strong><ul>"#);
for err in &errors {
error_section.push_str(&format!("<li>{}</li>", html_escape(err)));
}
error_section.push_str("</ul></div>");
}
let content = format!(
r#"<h1>Export Complete</h1>
<div class="flash success">
<strong>Results:</strong>
{exported} document(s) exported (CSAF {mode}) to <code>{export_dir}</code>.
</div>
{error_section}
<a class="btn" href="/csaf">View Documents</a>
<a class="btn secondary" href="/admin/export" style="margin-left:0.5rem;">Export Again</a>"#,
mode = html_escape(&settings.csaf_mode),
export_dir = html_escape(&settings.export_directory),
);
html_response(
StatusCode::OK,
wrap_page("Export Complete", &settings.theme, Nav::Export, &content),
)
}
pub async fn dump_execute(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let data_dir = state.config().data_dir.clone();
let dump_dir = Path::new(&settings.dump_directory).to_path_buf();
let result = dump_database(
&data_dir,
&dump_dir,
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 content = format!(
r#"<h1>Dump Complete</h1>
<div class="flash success">
<strong>Results:</strong> database snapshot written to
<code>{dump_dir}</code> with timestamp <code>{ts}</code>.
</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</h2>
<ul>{sidecar_list}</ul>
</div>
<a class="btn" href="/admin/export">Back to Export</a>"#,
dump_dir = html_escape(&settings.dump_directory),
ts = html_escape(&dump.timestamp),
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,
);
html_response(
StatusCode::OK,
wrap_page("Dump Complete", &settings.theme, Nav::Export, &content),
)
},
Err(err) => {
let content = format!(
r#"<h1>Dump Failed</h1>
<div class="flash error">
<strong>Error:</strong> {err}
</div>
<a class="btn secondary" href="/admin/export">Back to Export</a>"#,
err = html_escape(&err.to_string()),
);
html_response(
StatusCode::INTERNAL_SERVER_ERROR,
wrap_page("Dump Failed", &settings.theme, Nav::Export, &content),
)
},
}
}
pub async fn audit_export_execute(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
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 content = r#"<h1>Audit Log Export</h1>
<div class="flash error">
No output format was selected. Please tick at least one of Markdown,
CSV, JSON, or SARIF.
</div>
<a class="btn secondary" href="/admin/export">Back to Export</a>"#
.to_owned();
return html_response(
StatusCode::BAD_REQUEST,
wrap_page("Audit Log Export", &settings.theme, Nav::Export, &content),
);
}
let out_dir = Path::new(&settings.export_directory).to_path_buf();
let result = export_audit_log(state.db_pool(), &out_dir, &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 content = format!(
r#"<h1>Audit Log Export Complete</h1>
<div class="flash success">
<strong>Results:</strong>
{rows} audit row(s) exported into {payloads} payload file(s) with {sidecars} sidecar(s),
timestamp <code>{ts}</code>.
</div>
<div class="card">
<h2>Payloads</h2>
<ul>{payload_list}</ul>
<h2>Sidecars</h2>
<ul>{sidecar_list}</ul>
</div>
<a class="btn" href="/admin/export">Back to Export</a>"#,
rows = res.rows,
payloads = res.written.len(),
sidecars = res.sidecars.len(),
ts = html_escape(&res.timestamp),
);
html_response(
StatusCode::OK,
wrap_page(
"Audit Log Export Complete",
&settings.theme,
Nav::Export,
&content,
),
)
},
Err(err) => {
let content = format!(
r#"<h1>Audit Log Export Failed</h1>
<div class="flash error">
<strong>Error:</strong> {err}
</div>
<a class="btn secondary" href="/admin/export">Back to Export</a>"#,
err = html_escape(&err.to_string()),
);
html_response(
StatusCode::INTERNAL_SERVER_ERROR,
wrap_page(
"Audit Log Export Failed",
&settings.theme,
Nav::Export,
&content,
),
)
},
}
}