use askama::Template; use axum::{ Router, body::Body, extract::{Path, State}, http::{StatusCode, header}, response::{Html, Redirect, Response}, routing::{get, post}, }; use rusqlite::params; use zip::write::FileOptions; use crate::auth::CurrentUser; use crate::routes::AppState; #[derive(Template)] #[template(path = "backups/list.html")] struct BackupListTemplate { username: String, is_superuser: bool, password_change_required: bool, instance_name: String, instance_id: i64, backups: Vec, } struct BackupRow { id: i64, filename: String, created_at: String, } pub fn router() -> Router { Router::new() .route("/instances/{id}/backups", get(list_backups)) .route("/instances/{id}/backups/create", post(create_backup)) .route("/backups/{id}/restore", post(restore_backup)) .route("/backups/{id}/download", get(download_backup)) } async fn list_backups( State(state): State, Path(instance_id): Path, CurrentUser(account): CurrentUser, ) -> Result, axum::response::Redirect> { let conn = state.db.conn(); let instance_name: String = conn .query_row( "SELECT name FROM instances WHERE id = ?1", params![instance_id], |row| row.get(0), ) .map_err(|_| Redirect::to("/instances"))?; let mut stmt = conn .prepare("SELECT id, filename, created_at FROM backups WHERE instance_id = ?1 ORDER BY created_at DESC") .map_err(|_| Redirect::to("/instances"))?; let backups: Vec = stmt .query_map(params![instance_id], |row| { Ok(BackupRow { id: row.get(0)?, filename: row.get(1)?, created_at: row.get(2)?, }) }) .map_err(|_| Redirect::to("/instances"))? .filter_map(|r| r.ok()) .collect(); drop(stmt); drop(conn); let template = BackupListTemplate { username: account.username.clone(), is_superuser: account.is_superuser, password_change_required: account.password_change_required, instance_name, instance_id, backups, }; Ok(Html(template.render().unwrap_or_default())) } async fn create_backup( State(state): State, Path(instance_id): Path, CurrentUser(_user): CurrentUser, ) -> Result { let conn = state.db.conn(); let instance_name: String = conn .query_row( "SELECT name FROM instances WHERE id = ?1", params![instance_id], |row| row.get(0), ) .map_err(|_| Redirect::to("/instances"))?; drop(conn); let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); let filename = format!("{instance_name}_{timestamp}.zip"); let data_dir = state .config .server_directory .join("instances") .join(&instance_name) .join("data"); let backup_dir = state.config.server_directory.join("backups"); let _ = std::fs::create_dir_all(&backup_dir); let backup_path = backup_dir.join(&filename); if let Ok(file) = std::fs::File::create(&backup_path) { let mut zip = zip::ZipWriter::new(file); let options: FileOptions<'_, ()> = FileOptions::default().compression_method(zip::CompressionMethod::Deflated); if let Ok(entries) = std::fs::read_dir(&data_dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { if let Some(name) = path.file_name() { if let Ok(contents) = std::fs::read(&path) { let _ = zip.start_file(&*name.to_string_lossy(), options); let _ = std::io::Write::write_all(&mut zip, &contents); } } } } } let _ = zip.finish(); } let conn = state.db.conn(); conn.execute( "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", params![instance_id, filename], ) .ok(); drop(conn); Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) } async fn restore_backup( State(state): State, Path(backup_id): Path, CurrentUser(_user): CurrentUser, ) -> Result { let conn = state.db.conn(); let (instance_id, _filename): (i64, String) = conn .query_row( "SELECT instance_id, filename FROM backups WHERE id = ?1", params![backup_id], |row| Ok((row.get(0)?, row.get(1)?)), ) .map_err(|_| Redirect::to("/instances"))?; drop(conn); Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) } async fn download_backup( State(state): State, Path(backup_id): Path, CurrentUser(_user): CurrentUser, ) -> Result { let conn = state.db.conn(); let (_, filename): (i64, String) = conn .query_row( "SELECT instance_id, filename FROM backups WHERE id = ?1", params![backup_id], |row| Ok((row.get(0)?, row.get(1)?)), ) .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; drop(conn); let backup_path = state .config .server_directory .join("backups") .join(&filename); let content = std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; Ok(Response::builder() .header(header::CONTENT_TYPE, "application/zip") .header( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""), ) .body(Body::from(content)) .unwrap()) }