csaf-crud 0.2.0

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Import and export administration route handlers.
//!
//! Provides pages for importing CSAF documents from a directory and
//! exporting all stored documents to the filesystem.

use std::path::Path;

use http::{Response, StatusCode};

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, html_response};
use crate::routes::layout::{Nav, html_escape, wrap_page};

// ---------------------------------------------------------------------------
// Import handlers
// ---------------------------------------------------------------------------

/// `GET /admin/import` -- Import configuration 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),
    )
}

/// `POST /admin/import` -- Execute the import.
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),
            )
        },
    }
}

// ---------------------------------------------------------------------------
// Export handlers
// ---------------------------------------------------------------------------

/// `GET /admin/export` -- Export configuration page.
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>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 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>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>"#,
        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"
        },
        sha3 = if settings.sidecar_sha3_512 {
            "enabled"
        } else {
            "disabled"
        },
    );

    html_response(
        StatusCode::OK,
        wrap_page("Export", &settings.theme, Nav::Export, &content),
    )
}

/// `POST /admin/export` -- Execute the export.
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));
            },
        }
    }

    // Audit log.
    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),
    )
}

// ---------------------------------------------------------------------------
// Database dump handler
// ---------------------------------------------------------------------------

/// `POST /admin/dump` -- Snapshot the live database into the Dump Directory.
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) => {
            // Audit log the 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),
            )
        },
    }
}