use http::{Response, StatusCode};
use csaf_models::settings::{
CLASSIFICATION_STORAGE_MODES, NATO_LABELS, Settings, TLP_LABELS, VERSCHLUSSSACHE_LABELS,
is_valid_classification_storage_mode, is_valid_nato, is_valid_publisher_category,
is_valid_storage_path, is_valid_tlp, is_valid_verschlusssache,
};
use crate::app_state::AppState;
use crate::router::{Body, form_checkbox, form_field, html_response, parse_form_body, redirect};
use crate::routes::layout::{Nav, html_escape, wrap_page};
pub async fn settings_form(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
render_settings_form(&settings, None, None)
}
pub async fn settings_update(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let form = match parse_form_body(&parts) {
Ok(f) => f,
Err(err) => {
let current = state.settings();
return render_settings_form(
¤t,
Some(&format!("Invalid form data: {err}")),
None,
);
},
};
let csaf_mode = form_field(&form, "csaf_mode").unwrap_or("2.1").to_owned();
let theme = form_field(&form, "theme").unwrap_or("light").to_owned();
let import_directory = form_field(&form, "import_directory")
.unwrap_or("./data_import")
.to_owned();
let export_directory = form_field(&form, "export_directory")
.unwrap_or("./data_export")
.to_owned();
let dump_directory = form_field(&form, "dump_directory")
.unwrap_or("./data_dump")
.to_owned();
let log_directory = form_field(&form, "log_directory")
.unwrap_or("./data_log")
.to_owned();
let naming_convention = form_field(&form, "naming_convention")
.unwrap_or("ndaal-sa-")
.to_owned();
let sidecar_sha256 = form_checkbox(&form, "sidecar_sha256");
let sidecar_sha512 = form_checkbox(&form, "sidecar_sha512");
let sidecar_sha3_512 = form_checkbox(&form, "sidecar_sha3_512");
let sidecar_blake3_512 = form_checkbox(&form, "sidecar_blake3_512");
let sidecar_shake256_512 = form_checkbox(&form, "sidecar_shake256_512");
let publisher_name = form_field(&form, "publisher_name")
.unwrap_or("")
.trim()
.to_owned();
let publisher_namespace = form_field(&form, "publisher_namespace")
.unwrap_or("")
.trim()
.to_owned();
let publisher_category = form_field(&form, "publisher_category")
.unwrap_or("vendor")
.trim()
.to_owned();
let publisher_contact_details = form_field(&form, "publisher_contact_details")
.unwrap_or("")
.trim()
.to_owned();
let tlp_default = form_field(&form, "tlp_default")
.unwrap_or("AMBER")
.trim()
.to_owned();
let verschlusssache_enabled = form_checkbox(&form, "verschlusssache_enabled");
let verschlusssache_default = form_field(&form, "verschlusssache_default")
.unwrap_or("VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)")
.trim()
.to_owned();
let nato_enabled = form_checkbox(&form, "nato_enabled");
let nato_default = form_field(&form, "nato_default")
.unwrap_or("NR (NATO RESTRICTED)")
.trim()
.to_owned();
let classification_storage_mode = form_field(&form, "classification_storage_mode")
.unwrap_or("both")
.trim()
.to_owned();
if let Err(msg) = validate_settings_form(
&csaf_mode,
&theme,
&import_directory,
&export_directory,
&dump_directory,
&log_directory,
&publisher_name,
&publisher_namespace,
&publisher_category,
&tlp_default,
&verschlusssache_default,
&nato_default,
&classification_storage_mode,
) {
let current = state.settings();
return render_settings_form(¤t, Some(msg), None);
}
let new_settings = Settings {
csaf_mode,
theme,
import_directory,
export_directory,
dump_directory,
log_directory,
sidecar_sha256,
sidecar_sha512,
sidecar_sha3_512,
sidecar_blake3_512,
sidecar_shake256_512,
naming_convention,
publisher_name,
publisher_namespace,
publisher_category,
publisher_contact_details,
tlp_default,
verschlusssache_enabled,
verschlusssache_default,
nato_enabled,
nato_default,
classification_storage_mode,
};
match state.update_settings(new_settings.clone()) {
Ok(()) => render_settings_form(&new_settings, None, Some("Settings saved successfully.")),
Err(err) => {
let current = state.settings();
render_settings_form(
¤t,
Some(&format!("Failed to save settings: {err}")),
None,
)
},
}
}
#[allow(clippy::too_many_arguments)]
fn validate_settings_form(
csaf_mode: &str,
theme: &str,
import_directory: &str,
export_directory: &str,
dump_directory: &str,
log_directory: &str,
publisher_name: &str,
publisher_namespace: &str,
publisher_category: &str,
tlp_default: &str,
verschlusssache_default: &str,
nato_default: &str,
classification_storage_mode: &str,
) -> Result<(), &'static str> {
if csaf_mode != "2.0" && csaf_mode != "2.1" {
return Err("CSAF mode must be '2.0' or '2.1'.");
}
if theme != "light" && theme != "dark" {
return Err("Theme must be 'light' or 'dark'.");
}
if !is_valid_storage_path(import_directory) {
return Err("Import Directory contains invalid or unsafe path characters.");
}
if !is_valid_storage_path(export_directory) {
return Err("Export Directory contains invalid or unsafe path characters.");
}
if !is_valid_storage_path(dump_directory) {
return Err("Dump Directory contains invalid or unsafe path characters.");
}
if !is_valid_storage_path(log_directory) {
return Err("Log Directory contains invalid or unsafe path characters.");
}
if publisher_name.is_empty() {
return Err("Publisher name must not be empty.");
}
if publisher_namespace.is_empty() {
return Err("Publisher namespace must not be empty.");
}
if !is_valid_publisher_category(publisher_category) {
return Err(
"Publisher category must be one of: vendor, discoverer, coordinator, \
user, translator, other.",
);
}
if !is_valid_tlp(tlp_default) {
return Err("TLP 2.0 default must be one of: CLEAR, GREEN, AMBER, AMBER+STRICT, RED.");
}
if !is_valid_verschlusssache(verschlusssache_default) {
return Err(
"Verschlusssache default must be one of the four official German \
classification labels.",
);
}
if !is_valid_nato(nato_default) {
return Err(
"NATO default must be one of: NR (NATO RESTRICTED), NC (NATO CONFIDENTIAL), \
NS (NATO SECRET), CTS (COSMIC TOP SECRET).",
);
}
if !is_valid_classification_storage_mode(classification_storage_mode) {
return Err("Classification storage mode must be 'distribution_text', 'notes', or 'both'.");
}
Ok(())
}
pub async fn settings_reset(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let defaults = Settings::default();
match state.update_settings(defaults.clone()) {
Ok(()) => {
let _ = state.db_pool().with_conn(|conn| {
csaf_models::audit_log::record(
conn,
"settings_reset",
"all",
None,
Some("Settings restored to factory defaults."),
)
});
render_settings_form(
&defaults,
None,
Some("All settings were restored to defaults."),
)
},
Err(err) => {
let current = state.settings();
render_settings_form(
¤t,
Some(&format!("Failed to reset settings: {err}")),
None,
)
},
}
}
pub async fn toggle_theme(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let mut new_settings = state.settings();
new_settings.theme = if new_settings.theme == "dark" {
"light".to_owned()
} else {
"dark".to_owned()
};
let _ = state.update_settings(new_settings);
let location = parts
.headers
.get("referer")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.unwrap_or_else(|| "/".to_owned());
redirect(&location)
}
fn render_settings_form(
settings: &Settings,
error: Option<&str>,
success: Option<&str>,
) -> Response<Body> {
let html = wrap_page(
"Settings",
&settings.theme,
Nav::Settings,
&render_settings_content(settings, error, success),
);
html_response(StatusCode::OK, html)
}
fn render_settings_content(
settings: &Settings,
error: Option<&str>,
success: Option<&str>,
) -> String {
use std::fmt::Write as _;
let error_html = error
.map(|e| format!(r#"<div class="flash error">{}</div>"#, html_escape(e)))
.unwrap_or_default();
let success_html = success
.map(|s| format!(r#"<div class="flash success">{}</div>"#, html_escape(s)))
.unwrap_or_default();
let sel = |v: &str, c: &str| if v == c { "selected" } else { "" };
let csaf_20_selected = sel(&settings.csaf_mode, "2.0");
let csaf_21_selected = sel(&settings.csaf_mode, "2.1");
let theme_light_selected = sel(&settings.theme, "light");
let theme_dark_selected = sel(&settings.theme, "dark");
let sha256_checked = if settings.sidecar_sha256 {
"checked"
} else {
""
};
let sha512_checked = if settings.sidecar_sha512 {
"checked"
} else {
""
};
let sha3_checked = if settings.sidecar_sha3_512 {
"checked"
} else {
""
};
let blake3_checked = if settings.sidecar_blake3_512 {
"checked"
} else {
""
};
let shake256_checked = if settings.sidecar_shake256_512 {
"checked"
} else {
""
};
let pc_vendor = sel(&settings.publisher_category, "vendor");
let pc_disc = sel(&settings.publisher_category, "discoverer");
let pc_coord = sel(&settings.publisher_category, "coordinator");
let pc_user = sel(&settings.publisher_category, "user");
let pc_trans = sel(&settings.publisher_category, "translator");
let pc_other = sel(&settings.publisher_category, "other");
let tlp_options = TLP_LABELS.iter().fold(String::new(), |mut out, label| {
let _ = write!(
out,
r#"<option value="{label}" {sel}>TLP:{label}</option>"#,
label = html_escape(label),
sel = sel(&settings.tlp_default, label),
);
out
});
let vs_options = VERSCHLUSSSACHE_LABELS
.iter()
.fold(String::new(), |mut out, label| {
let _ = write!(
out,
r#"<option value="{esc}" {sel}>{esc}</option>"#,
esc = html_escape(label),
sel = sel(&settings.verschlusssache_default, label),
);
out
});
let nato_options = NATO_LABELS.iter().fold(String::new(), |mut out, label| {
let _ = write!(
out,
r#"<option value="{esc}" {sel}>{esc}</option>"#,
esc = html_escape(label),
sel = sel(&settings.nato_default, label),
);
out
});
let storage_options =
CLASSIFICATION_STORAGE_MODES
.iter()
.fold(String::new(), |mut out, mode| {
let label = match *mode {
"distribution_text" => "Only document.distribution.text",
"notes" => "Only document.notes[]",
_ => "Both (distribution.text AND notes[])",
};
let _ = write!(
out,
r#"<option value="{mode}" {sel}>{label}</option>"#,
sel = sel(&settings.classification_storage_mode, mode),
);
out
});
let vs_checked = if settings.verschlusssache_enabled {
"checked"
} else {
""
};
let nato_checked = if settings.nato_enabled { "checked" } else { "" };
let content = format!(
r#"<h1>Settings</h1>
{error_html}
{success_html}
<div class="card">
<form method="POST" action="/settings">
<h2>General</h2>
<div class="form-row">
<div>
<label for="csaf_mode">CSAF Mode *</label>
<select name="csaf_mode" id="csaf_mode">
<option value="2.0" {csaf_20_selected}>CSAF 2.0</option>
<option value="2.1" {csaf_21_selected}>CSAF 2.1</option>
</select>
<div class="muted">Determines validation rules and the
<code>csaf_version</code> written into exported files.</div>
</div>
<div>
<label for="theme">Theme</label>
<select name="theme" id="theme">
<option value="light" {theme_light_selected}>Light</option>
<option value="dark" {theme_dark_selected}>Dark</option>
</select>
<div class="muted">You can also use the toggle in the top navigation bar.</div>
</div>
</div>
<div class="form-row">
<div>
<label for="import_directory">Import Directory</label>
<input type="text" name="import_directory" id="import_directory" value="{import_dir}">
<div class="muted">Filesystem path scanned for CSAF JSON files during import.</div>
</div>
<div>
<label for="export_directory">Export Directory</label>
<input type="text" name="export_directory" id="export_directory" value="{export_dir}">
<div class="muted">Filesystem path where exported CSAF files and sidecars are written.</div>
</div>
</div>
<div class="form-row">
<div>
<label for="dump_directory">Dump Directory</label>
<input type="text" name="dump_directory" id="dump_directory" value="{dump_dir}">
<div class="muted">Filesystem path where the database dump files and sidecars are written.</div>
</div>
<div>
<label for="log_directory">Log Directory</label>
<input type="text" name="log_directory" id="log_directory" value="{log_dir}">
<div class="muted">Filesystem path where the application log files and sidecars are written.</div>
</div>
</div>
<div class="form-row single">
<div>
<label for="naming_convention">Naming Convention Prefix</label>
<input type="text" name="naming_convention" id="naming_convention" value="{naming}">
<div class="muted">Prefix used for tracking IDs (e.g. <code>ndaal-sa-</code>).</div>
</div>
</div>
<div class="form-row single">
<div>
<label>Sidecar Hash Files</label>
<div><label style="font-weight:normal;"><input type="checkbox" name="sidecar_sha256" {sha256_checked}> Generate SHA-256 hashes sidecars (extension <code>.sha-256</code>)</label></div>
<div><label style="font-weight:normal;"><input type="checkbox" name="sidecar_sha512" {sha512_checked}> Generate SHA-512 hashes sidecars (extension <code>.sha-512</code>)</label></div>
<div><label style="font-weight:normal;"><input type="checkbox" name="sidecar_sha3_512" {sha3_checked}> Generate SHA3-512 hashes sidecars (extension <code>.sha3-512</code>)</label></div>
<div><label style="font-weight:normal;"><input type="checkbox" name="sidecar_blake3_512" {blake3_checked}> Generate BLAKE3-512 hashes sidecars (extension <code>.blake3-512</code>)</label></div>
<div><label style="font-weight:normal;"><input type="checkbox" name="sidecar_shake256_512" {shake256_checked}> Generate SHAKE256-512 hashes sidecars (extension <code>.shake256-512</code>)</label></div>
</div>
</div>
<h2>Classification</h2>
<p class="muted">Defaults for the colored TLP 2.0 selector, the German
Verschlusssache field, and the NATO classification field. These values
prefill the CSAF create form; per-document overrides are still possible.</p>
<div class="form-row">
<div>
<label for="tlp_default">TLP 2.0 default *</label>
<select name="tlp_default" id="tlp_default" class="tlp-select" required>
{tlp_options}
</select>
<div class="muted">Colour-coding per
<a href="https://www.first.org/tlp/" target="_blank" rel="noopener">FIRST TLP 2.0</a>.</div>
</div>
<div>
<label for="classification_storage_mode">Storage mode *</label>
<select name="classification_storage_mode" id="classification_storage_mode" required>
{storage_options}
</select>
<div class="muted">Where to write Verschlusssache / NATO values in
the exported CSAF JSON. <code>both</code> writes to
<code>document.distribution.text</code> AND
<code>document.notes[]</code> so any consumer can find them.</div>
</div>
</div>
<div class="form-row">
<div>
<label style="font-weight:normal;">
<input type="checkbox" name="verschlusssache_enabled" {vs_checked}>
Enable Verschlusssache
</label>
<label for="verschlusssache_default">Verschlusssache default</label>
<select name="verschlusssache_default" id="verschlusssache_default" required>
{vs_options}
</select>
<div class="muted">German national classification per
<a href="https://de.wikipedia.org/wiki/Verschlusssache#Einstufung"
target="_blank" rel="noopener">§ Verschlusssache</a>.</div>
</div>
<div>
<label style="font-weight:normal;">
<input type="checkbox" name="nato_enabled" {nato_checked}>
Enable NATO
</label>
<label for="nato_default">NATO default</label>
<select name="nato_default" id="nato_default" required>
{nato_options}
</select>
<div class="muted">NATO classification per
<a href="https://de.wikipedia.org/wiki/Geheimhaltungsgrad#NATO"
target="_blank" rel="noopener">§ Geheimhaltungsgrad</a>.</div>
</div>
</div>
<h2>Publisher Defaults</h2>
<p class="muted">These values are prefilled into the CSAF create form and
inherited by every new document. You can still override them per document
at creation time.</p>
<div class="form-row">
<div>
<label for="publisher_name">Name *</label>
<input type="text" name="publisher_name" id="publisher_name" required value="{publisher_name}">
</div>
<div>
<label for="publisher_namespace">Namespace *</label>
<input type="url" name="publisher_namespace" id="publisher_namespace" required value="{publisher_namespace}">
</div>
</div>
<div class="form-row">
<div>
<label for="publisher_category">Category *</label>
<select name="publisher_category" id="publisher_category" required>
<option value="vendor" {pc_vendor}>vendor</option>
<option value="discoverer" {pc_disc}>discoverer</option>
<option value="coordinator" {pc_coord}>coordinator</option>
<option value="user" {pc_user}>user</option>
<option value="translator" {pc_trans}>translator</option>
<option value="other" {pc_other}>other</option>
</select>
<div class="muted">OASIS CSAF publisher category.</div>
</div>
<div>
<label for="publisher_contact_details">Contact details</label>
<input type="text" name="publisher_contact_details" id="publisher_contact_details" value="{publisher_contact}">
<div class="muted">Usually an email address or security contact URL.</div>
</div>
</div>
<button type="submit" class="btn">Save Settings</button>
</form>
<hr style="margin:1.5rem 0;">
<form method="POST" action="/settings/reset"
onsubmit="return confirm('Reset ALL settings to their default values? This cannot be undone.');">
<button type="submit" class="btn secondary" id="reset-settings-btn">Reset to default settings</button>
<div class="muted" style="margin-top:0.25rem;">Restores every field on this page to the application defaults.</div>
</form>
</div>"#,
import_dir = html_escape(&settings.import_directory),
export_dir = html_escape(&settings.export_directory),
dump_dir = html_escape(&settings.dump_directory),
log_dir = html_escape(&settings.log_directory),
naming = html_escape(&settings.naming_convention),
publisher_name = html_escape(&settings.publisher_name),
publisher_namespace = html_escape(&settings.publisher_namespace),
publisher_contact = html_escape(&settings.publisher_contact_details),
);
content
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_settings_form_default() {
let settings = Settings::default();
let resp = render_settings_form(&settings, None, None);
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn test_render_settings_form_with_error() {
let settings = Settings::default();
let resp = render_settings_form(&settings, Some("test error"), None);
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn test_render_settings_form_with_success() {
let settings = Settings::default();
let resp = render_settings_form(&settings, None, Some("saved"));
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn test_render_settings_content_includes_publisher_fields() {
let settings = Settings::default();
let html = render_settings_content(&settings, None, None);
assert!(html.contains(r#"name="publisher_name""#));
assert!(html.contains(r#"name="publisher_namespace""#));
assert!(html.contains(r#"name="publisher_category""#));
assert!(html.contains(r#"name="publisher_contact_details""#));
assert!(html.contains("Publisher Defaults"));
assert!(html.contains(r#"<option value="vendor" selected>"#));
}
#[test]
fn test_render_settings_content_labels_include_hashes() {
let settings = Settings::default();
let html = render_settings_content(&settings, None, None);
assert!(
html.contains("Generate SHA-256 hashes sidecars"),
"expected SHA-256 label in HTML"
);
assert!(
html.contains("Generate SHA-512 hashes sidecars"),
"expected SHA-512 label in HTML"
);
assert!(
html.contains("Generate SHA3-512 hashes sidecars"),
"expected SHA3-512 label in HTML"
);
assert!(!html.contains(">Generate SHA-256 sidecars<"));
assert!(!html.contains(">Generate SHA3-512 sidecars<"));
}
#[test]
fn test_render_settings_content_shows_sha512_checkbox() {
let settings = Settings::default();
let html = render_settings_content(&settings, None, None);
assert!(
html.contains(r#"name="sidecar_sha512""#),
"expected SHA-512 checkbox input in HTML"
);
assert!(
html.contains("<code>.sha-512</code>"),
"expected SHA-512 extension help text"
);
}
#[test]
fn test_render_settings_content_shows_log_directory() {
let settings = Settings::default();
let html = render_settings_content(&settings, None, None);
assert!(
html.contains(r#"name="log_directory""#),
"expected log_directory input in HTML"
);
assert!(
html.contains(r#"value="./data_log""#),
"expected default log directory value"
);
assert!(
html.contains(
"Filesystem path where the application log files and sidecars are written."
)
);
}
#[test]
fn test_render_settings_content_shows_reset_button() {
let settings = Settings::default();
let html = render_settings_content(&settings, None, None);
assert!(
html.contains("/settings/reset"),
"expected reset form action in HTML"
);
assert!(
html.contains("Reset to default settings"),
"expected reset button label"
);
assert!(
html.contains("onsubmit=\"return confirm(")
|| html.contains("onsubmit='return confirm("),
"expected native confirm() guard on reset form"
);
}
#[test]
fn test_render_settings_content_shows_dump_directory() {
let settings = Settings::default();
let html = render_settings_content(&settings, None, None);
assert!(
html.contains(r#"name="dump_directory""#),
"expected dump_directory input in HTML"
);
assert!(
html.contains(r#"value="./data_dump""#),
"expected default dump directory value"
);
assert!(
html.contains(
"Filesystem path where the database dump files and sidecars are written."
)
);
}
#[test]
fn test_render_settings_content_prefills_non_default_dump_directory() {
let settings = Settings {
dump_directory: "/backup/csaf".to_owned(),
..Settings::default()
};
let html = render_settings_content(&settings, None, None);
assert!(html.contains(r#"value="/backup/csaf""#));
}
#[test]
fn test_render_settings_content_prefills_non_default_publisher() {
let settings = Settings {
publisher_name: "Acme Security".to_owned(),
publisher_namespace: "https://acme.example/csaf".to_owned(),
publisher_category: "coordinator".to_owned(),
publisher_contact_details: "psirt@acme.example".to_owned(),
..Settings::default()
};
let html = render_settings_content(&settings, None, None);
assert!(html.contains("Acme Security"));
assert!(html.contains("acme.example/csaf"));
assert!(html.contains("psirt@acme.example"));
assert!(html.contains(r#"<option value="coordinator" selected>"#));
assert!(!html.contains(r#"<option value="vendor" selected>"#));
}
}