use askama::Template;
use axum::body::Body;
use axum::extract::{Multipart, Query};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use serde::Deserialize;
use crate::config::{self, SshConfigEntry};
use crate::export;
use crate::i18n::{Locale, LocaleMap};
#[derive(Template)]
#[template(path = "config.html")]
pub struct ConfigTemplate {
pub entries: Vec<ConfigEntryView>,
pub flash: Option<String>,
pub flash_is_error: bool,
pub t: &'static LocaleMap,
}
pub struct ConfigEntryView {
pub host_pattern: String,
pub fields: Vec<(String, String)>,
pub identity_file: Option<String>,
}
#[derive(Deserialize)]
pub struct ConfigQuery {
pub flash: Option<String>,
pub flash_error: Option<String>,
pub flash_param: Option<String>,
}
pub async fn config_page(Query(query): Query<ConfigQuery>, headers: HeaderMap) -> Html<String> {
let locale =
Locale::from_accept_language(headers.get("accept-language").and_then(|v| v.to_str().ok()));
let entries = config::parse_config();
let views: Vec<ConfigEntryView> = entries
.iter()
.map(|e| {
let identity_file = e
.fields
.iter()
.find(|(k, _)| k == "IdentityFile")
.map(|(_, v)| v.clone());
ConfigEntryView {
host_pattern: e.host_pattern.clone(),
fields: config::get_entry_fields(e),
identity_file,
}
})
.collect();
let (flash, flash_is_error) = if let Some(msg) = query.flash_error {
(
Some(locale.resolve_flash(&msg, query.flash_param.as_deref())),
true,
)
} else if let Some(msg) = query.flash {
(
Some(locale.resolve_flash(&msg, query.flash_param.as_deref())),
false,
)
} else {
(None, false)
};
let tmpl = ConfigTemplate {
entries: views,
flash,
flash_is_error,
t: locale.map,
};
Html(
tmpl.render()
.unwrap_or_else(|e| format!("Template error: {}", e)),
)
}
#[derive(Template)]
#[template(path = "config_edit.html")]
pub struct ConfigEditTemplate {
pub host_pattern: String,
pub fields_json: String,
pub available_keys_json: String,
pub is_new: bool,
pub t: &'static LocaleMap,
pub js_locale_json: String,
}
#[derive(Deserialize)]
pub struct EditQuery {
pub host: Option<String>,
}
const DEFAULT_FIELDS: &[&str] = &["HostName", "User", "Port", "IdentityFile"];
fn merge_with_defaults(fields: &[(String, String)]) -> Vec<(String, String)> {
let mut result: Vec<(String, String)> = fields.to_vec();
let existing_keys: std::collections::HashSet<String> =
fields.iter().map(|(k, _)| k.to_lowercase()).collect();
for &default_key in DEFAULT_FIELDS {
if !existing_keys.contains(&default_key.to_lowercase()) {
result.push((default_key.to_string(), String::new()));
}
}
result
}
fn available_keys_json() -> String {
let keys = crate::ssh::list_keys();
let names: Vec<&str> = keys.iter().map(|k| k.name.as_str()).collect();
serde_json::to_string(&names).unwrap_or_else(|_| "[]".to_string())
}
fn make_js_locale_json(locale: &Locale) -> String {
let obj: serde_json::Value = serde_json::json!({
"select_key": locale.t("js_select_key"),
"custom_path": locale.t("js_custom_path"),
"key_placeholder": locale.t("js_key_placeholder"),
"value_placeholder": locale.t("js_value_placeholder"),
"remove": locale.t("js_remove"),
});
serde_json::to_string(&obj).unwrap_or_else(|_| "{}".to_string())
}
pub async fn config_edit(Query(query): Query<EditQuery>, headers: HeaderMap) -> Html<String> {
let locale =
Locale::from_accept_language(headers.get("accept-language").and_then(|v| v.to_str().ok()));
let entries = config::parse_config();
let akj = available_keys_json();
let jsl = make_js_locale_json(&locale);
if let Some(ref host) = query.host
&& let Some(entry) = entries.iter().find(|e| &e.host_pattern == host)
{
let merged = merge_with_defaults(&entry.fields);
let fields_json = serde_json::to_string(&merged).unwrap_or_else(|_| "[]".to_string());
let tmpl = ConfigEditTemplate {
host_pattern: entry.host_pattern.clone(),
fields_json,
available_keys_json: akj,
is_new: false,
t: locale.map,
js_locale_json: jsl,
};
return Html(
tmpl.render()
.unwrap_or_else(|e| format!("Template error: {}", e)),
);
}
let defaults: Vec<(String, String)> = DEFAULT_FIELDS
.iter()
.map(|&k| (k.to_string(), String::new()))
.collect();
let fields_json = serde_json::to_string(&defaults).unwrap_or_else(|_| "[]".to_string());
let tmpl = ConfigEditTemplate {
host_pattern: String::new(),
fields_json,
available_keys_json: akj,
is_new: true,
t: locale.map,
js_locale_json: jsl,
};
Html(
tmpl.render()
.unwrap_or_else(|e| format!("Template error: {}", e)),
)
}
fn form_values(form: &axum::extract::RawForm, key: &str) -> Vec<String> {
let raw = String::from_utf8_lossy(&form.0);
let mut values = Vec::new();
for pair in raw.split('&') {
if let Some((k, v)) = pair.split_once('=') {
let k = urldecode(k);
let v = urldecode(v);
if k == key {
values.push(v);
}
}
}
values
}
fn form_single(form: &axum::extract::RawForm, key: &str) -> String {
form_values(form, key)
.into_iter()
.next()
.unwrap_or_default()
}
fn urldecode(s: &str) -> String {
let s = s.replace('+', " ");
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else {
result.push(c);
}
}
result
}
pub async fn config_save(axum::extract::RawForm(form): axum::extract::RawForm) -> Redirect {
let mut entries = config::parse_config();
let original_host = form_single(&axum::extract::RawForm(form.clone()), "original_host");
let host_pattern = form_single(&axum::extract::RawForm(form.clone()), "host_pattern");
let keys = form_values(&axum::extract::RawForm(form.clone()), "field_keys");
let vals = form_values(&axum::extract::RawForm(form.clone()), "field_values");
let fields: Vec<(String, String)> = keys
.iter()
.zip(vals.iter())
.filter(|(k, _)| !k.trim().is_empty())
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.filter(|(_, v)| !v.is_empty())
.collect();
if original_host.is_empty() {
entries.push(SshConfigEntry {
host_pattern: host_pattern.clone(),
fields,
});
} else if let Some(entry) = entries.iter_mut().find(|e| e.host_pattern == original_host) {
entry.host_pattern = host_pattern.clone();
entry.fields = fields;
} else {
return Redirect::to("/config?flash_error=flash_host_not_found");
}
match config::write_config(&entries) {
Ok(()) => Redirect::to(&format!(
"/config?flash=flash_saved&flash_param={}",
host_pattern
)),
Err(e) => Redirect::to(&format!("/config?flash_error={}", e.replace(' ', "+"))),
}
}
pub async fn config_add() -> Redirect {
Redirect::to("/config/edit")
}
#[derive(Deserialize)]
pub struct DeleteForm {
pub host: String,
}
pub async fn config_delete(axum::Form(form): axum::Form<DeleteForm>) -> Redirect {
let mut entries = config::parse_config();
let before = entries.len();
entries.retain(|e| e.host_pattern != form.host);
if entries.len() == before {
return Redirect::to("/config?flash_error=flash_host_not_found");
}
match config::write_config(&entries) {
Ok(()) => Redirect::to(&format!(
"/config?flash=flash_deleted&flash_param={}",
form.host
)),
Err(e) => Redirect::to(&format!("/config?flash_error={}", e.replace(' ', "+"))),
}
}
#[derive(Template)]
#[template(path = "config_raw.html")]
pub struct ConfigRawTemplate {
pub content: String,
pub flash: Option<String>,
pub flash_is_error: bool,
pub t: &'static LocaleMap,
}
pub async fn config_raw(Query(query): Query<ConfigQuery>, headers: HeaderMap) -> Html<String> {
let locale =
Locale::from_accept_language(headers.get("accept-language").and_then(|v| v.to_str().ok()));
let content = config::read_raw_config();
let (flash, flash_is_error) = if let Some(msg) = query.flash_error {
(
Some(locale.resolve_flash(&msg, query.flash_param.as_deref())),
true,
)
} else if let Some(msg) = query.flash {
(
Some(locale.resolve_flash(&msg, query.flash_param.as_deref())),
false,
)
} else {
(None, false)
};
let tmpl = ConfigRawTemplate {
content,
flash,
flash_is_error,
t: locale.map,
};
Html(
tmpl.render()
.unwrap_or_else(|e| format!("Template error: {}", e)),
)
}
#[derive(Deserialize)]
pub struct RawForm {
pub content: String,
}
pub async fn config_raw_save(axum::Form(form): axum::Form<RawForm>) -> Redirect {
match config::write_raw_config(&form.content) {
Ok(()) => Redirect::to("/config/raw?flash=flash_saved"),
Err(e) => Redirect::to(&format!("/config/raw?flash_error={}", e.replace(' ', "+"))),
}
}
pub async fn backup() -> Response {
let tmp_dir = std::env::temp_dir();
let dest = tmp_dir.join("ssh_backup.tar.gz");
if let Err(e) = export::backup_all(&dest) {
return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response();
}
let data = match std::fs::read(&dest) {
Ok(d) => d,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to read backup: {}", e),
)
.into_response();
}
};
let _ = std::fs::remove_file(&dest);
let now = chrono_like_timestamp();
let filename = format!("ssh_backup_{}.tar.gz", now);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/gzip")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
)
.body(Body::from(data))
.unwrap()
}
fn chrono_like_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}", secs)
}
pub async fn restore(mut multipart: Multipart) -> Redirect {
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
if name != "archive" {
continue;
}
let data = match field.bytes().await {
Ok(d) => d,
Err(e) => {
return Redirect::to(&format!(
"/?flash_error=flash_upload_failed&flash_param={}",
e.to_string().replace(' ', "+")
));
}
};
let tmp_dir = std::env::temp_dir();
let tmp_path = tmp_dir.join("sshkeyman_restore.tar.gz");
if let Err(e) = std::fs::write(&tmp_path, &data) {
return Redirect::to(&format!(
"/?flash_error=flash_write_failed&flash_param={}",
e.to_string().replace(' ', "+")
));
}
let result = export::restore_all(&tmp_path);
let _ = std::fs::remove_file(&tmp_path);
return match result {
Ok(files) => Redirect::to(&format!(
"/?flash=flash_restored&flash_param={}",
files.len()
)),
Err(e) => Redirect::to(&format!("/?flash_error={}", e.replace(' ', "+"))),
};
}
Redirect::to("/?flash_error=flash_no_file_uploaded")
}