Release v0.6.0
This commit is contained in:
1 parent
7069ca9db7
commit
f49698bb5c
75 files changed
+11199
-7508
No files matched your search
@@ -0,0 +1,300 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BackupFileRow {
|
||||
pub filename: String,
|
||||
pub size_str: String,
|
||||
pub created_str: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BackupHistoryRow {
|
||||
pub id: String,
|
||||
pub backup_path: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub size_bytes: i64,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
// GET /admin/backups
|
||||
pub async fn backups_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let mut files = vec![];
|
||||
if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) {
|
||||
for entry in dir_entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(filename) = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
if filename.ends_with(".tar.gz") {
|
||||
let meta = entry.metadata().unwrap();
|
||||
let size_str = format_size(meta.len());
|
||||
let created_str = meta
|
||||
.created()
|
||||
.ok()
|
||||
.map(|c| {
|
||||
let datetime: chrono::DateTime<chrono::Utc> = c.into();
|
||||
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
files.push(BackupFileRow {
|
||||
filename,
|
||||
size_str,
|
||||
created_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||
|
||||
let history = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(BackupHistoryRow {
|
||||
id: row.get(0)?,
|
||||
backup_path: row.get(1)?,
|
||||
status: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
size_bytes: row.get(4)?,
|
||||
error_message: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::BackupsTemplate {
|
||||
admin_username: user.username,
|
||||
files,
|
||||
history,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/backups/create
|
||||
pub async fn backups_create_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (_user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
match crate::jobs::backup::perform_backup(&state.db, &state.config).await {
|
||||
Ok(path) => {
|
||||
let filename = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("backup.tar.gz");
|
||||
Redirect::to(&format!(
|
||||
"/admin/backups?success=Backup created successfully: {}",
|
||||
filename
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/backups?error=Failed to generate backup: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/backups/download/:filename
|
||||
pub async fn backups_download_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(filename): Path<String>,
|
||||
) -> Response {
|
||||
if require_auth(&state, &jar).await.is_err() {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
let backup_path = state.config.backup_dir.join(&filename);
|
||||
if !backup_path.exists() {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
match std::fs::read(&backup_path) {
|
||||
Ok(bytes) => {
|
||||
let body = axum::body::Body::from(bytes);
|
||||
Response::builder()
|
||||
.header("content-type", "application/octet-stream")
|
||||
.header(
|
||||
"content-disposition",
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
)
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/backups/delete/:filename
|
||||
pub async fn backups_delete_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(filename): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Redirect::to("/admin/backups?error=Invalid filename").into_response();
|
||||
}
|
||||
|
||||
let backup_path = state.config.backup_dir.join(&filename);
|
||||
if !backup_path.exists() {
|
||||
return Redirect::to("/admin/backups?error=Backup file not found").into_response();
|
||||
}
|
||||
|
||||
match std::fs::remove_file(&backup_path) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"BACKUP_DELETE",
|
||||
"backup",
|
||||
&filename,
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/backups?success=Backup archive deleted").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/backups?error=Failed to delete backup file: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/backups/restore
|
||||
pub async fn backups_restore_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let mut backup_bytes = vec![];
|
||||
let mut confirm_text = String::new();
|
||||
let mut csrf_token = String::new();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "backup_file" {
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
backup_bytes = bytes.to_vec();
|
||||
}
|
||||
} else if name == "confirm_text" {
|
||||
if let Ok(text) = field.text().await {
|
||||
confirm_text = text.trim().to_string();
|
||||
}
|
||||
} else if name == "csrf_token" {
|
||||
if let Ok(text) = field.text().await {
|
||||
csrf_token = text.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
if confirm_text != "RESTORE" {
|
||||
return Redirect::to(
|
||||
"/admin/backups?error=Confirmation text mismatch. Please type RESTORE.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if backup_bytes.is_empty() {
|
||||
return Redirect::to("/admin/backups?error=Backup file is empty or missing.")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz");
|
||||
if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/backups?error=Failed to save uploaded file: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match crate::cli::restore::run(
|
||||
temp_file_path.to_string_lossy().to_string(),
|
||||
None,
|
||||
state.config.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions;", []);
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"RESTORE_EXECUTION",
|
||||
"backup",
|
||||
"upload",
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/login?success=Restore successful. Please log in again.")
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user