diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-12 19:38:03 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-12 19:38:03 +0200 |
| commit | ae7ef10b67737f08a86743f687bca30a12e9e17a (patch) | |
| tree | 056539ab76b801559dc8d07ee6599220e2bd8e07 /src/routes/backups.rs | |
| parent | architecture (diff) | |
| download | minecraft-ui-ae7ef10b67737f08a86743f687bca30a12e9e17a.tar.gz minecraft-ui-ae7ef10b67737f08a86743f687bca30a12e9e17a.zip | |
first stable
Diffstat (limited to 'src/routes/backups.rs')
| -rw-r--r-- | src/routes/backups.rs | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/src/routes/backups.rs b/src/routes/backups.rs new file mode 100644 index 0000000..09298d2 --- /dev/null +++ b/src/routes/backups.rs | |||
| @@ -0,0 +1,256 @@ | |||
| 1 | use askama::Template; | ||
| 2 | use axum::{ | ||
| 3 | Router, | ||
| 4 | body::Body, | ||
| 5 | extract::{Path, State}, | ||
| 6 | http::{StatusCode, header}, | ||
| 7 | response::{Html, Redirect, Response}, | ||
| 8 | routing::{get, post}, | ||
| 9 | }; | ||
| 10 | use rusqlite::params; | ||
| 11 | use zip::write::FileOptions; | ||
| 12 | |||
| 13 | use crate::auth::CurrentUser; | ||
| 14 | use crate::routes::AppState; | ||
| 15 | |||
| 16 | #[derive(Template)] | ||
| 17 | #[template(path = "backups/list.html")] | ||
| 18 | struct BackupListTemplate { | ||
| 19 | username: String, | ||
| 20 | is_superuser: bool, | ||
| 21 | password_change_required: bool, | ||
| 22 | instance_name: String, | ||
| 23 | instance_id: i64, | ||
| 24 | backups: Vec<BackupRow>, | ||
| 25 | message: Option<String>, | ||
| 26 | } | ||
| 27 | |||
| 28 | struct BackupRow { | ||
| 29 | id: i64, | ||
| 30 | filename: String, | ||
| 31 | created_at: String, | ||
| 32 | } | ||
| 33 | |||
| 34 | pub fn router() -> Router<AppState> { | ||
| 35 | Router::new() | ||
| 36 | .route("/instances/{id}/backups", get(list_backups)) | ||
| 37 | .route("/instances/{id}/backups/create", post(create_backup)) | ||
| 38 | .route("/backups/{id}/restore", post(restore_backup)) | ||
| 39 | .route("/backups/{id}/download", get(download_backup)) | ||
| 40 | .route("/instances/{id}/world/export", get(export_world)) | ||
| 41 | } | ||
| 42 | |||
| 43 | async fn list_backups( | ||
| 44 | State(state): State<AppState>, | ||
| 45 | Path(instance_id): Path<i64>, | ||
| 46 | CurrentUser(account): CurrentUser, | ||
| 47 | ) -> Result<Html<String>, axum::response::Redirect> { | ||
| 48 | let conn = state.db.conn(); | ||
| 49 | let instance_name: String = conn | ||
| 50 | .query_row( | ||
| 51 | "SELECT name FROM instances WHERE id = ?1", | ||
| 52 | params![instance_id], | ||
| 53 | |row| row.get(0), | ||
| 54 | ) | ||
| 55 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 56 | |||
| 57 | let mut stmt = conn | ||
| 58 | .prepare("SELECT id, filename, created_at FROM backups WHERE instance_id = ?1 ORDER BY created_at DESC") | ||
| 59 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 60 | |||
| 61 | let backups: Vec<BackupRow> = stmt | ||
| 62 | .query_map(params![instance_id], |row| { | ||
| 63 | Ok(BackupRow { | ||
| 64 | id: row.get(0)?, | ||
| 65 | filename: row.get(1)?, | ||
| 66 | created_at: row.get(2)?, | ||
| 67 | }) | ||
| 68 | }) | ||
| 69 | .map_err(|_| Redirect::to("/instances"))? | ||
| 70 | .filter_map(|r| r.ok()) | ||
| 71 | .collect(); | ||
| 72 | drop(stmt); | ||
| 73 | drop(conn); | ||
| 74 | |||
| 75 | let template = BackupListTemplate { | ||
| 76 | username: account.username.clone(), | ||
| 77 | is_superuser: account.is_superuser, | ||
| 78 | password_change_required: account.password_change_required, | ||
| 79 | instance_name, | ||
| 80 | instance_id, | ||
| 81 | backups, | ||
| 82 | message: None, | ||
| 83 | }; | ||
| 84 | |||
| 85 | Ok(Html(template.render().unwrap_or_default())) | ||
| 86 | } | ||
| 87 | |||
| 88 | async fn create_backup( | ||
| 89 | State(state): State<AppState>, | ||
| 90 | Path(instance_id): Path<i64>, | ||
| 91 | CurrentUser(_user): CurrentUser, | ||
| 92 | ) -> Result<Redirect, axum::response::Redirect> { | ||
| 93 | let conn = state.db.conn(); | ||
| 94 | let instance_name: String = conn | ||
| 95 | .query_row( | ||
| 96 | "SELECT name FROM instances WHERE id = ?1", | ||
| 97 | params![instance_id], | ||
| 98 | |row| row.get(0), | ||
| 99 | ) | ||
| 100 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 101 | drop(conn); | ||
| 102 | |||
| 103 | let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); | ||
| 104 | let filename = format!("{instance_name}_{timestamp}.zip"); | ||
| 105 | |||
| 106 | let data_dir = state | ||
| 107 | .config | ||
| 108 | .server_directory | ||
| 109 | .join("instances") | ||
| 110 | .join(&instance_name) | ||
| 111 | .join("data"); | ||
| 112 | |||
| 113 | let backup_dir = state.config.server_directory.join("backups"); | ||
| 114 | let _ = std::fs::create_dir_all(&backup_dir); | ||
| 115 | let backup_path = backup_dir.join(&filename); | ||
| 116 | |||
| 117 | if let Ok(file) = std::fs::File::create(&backup_path) { | ||
| 118 | let mut zip = zip::ZipWriter::new(file); | ||
| 119 | let options: FileOptions<'_, ()> = | ||
| 120 | FileOptions::default().compression_method(zip::CompressionMethod::Deflated); | ||
| 121 | |||
| 122 | if let Ok(entries) = std::fs::read_dir(&data_dir) { | ||
| 123 | for entry in entries.flatten() { | ||
| 124 | let path = entry.path(); | ||
| 125 | if path.is_file() { | ||
| 126 | if let Some(name) = path.file_name() { | ||
| 127 | if let Ok(contents) = std::fs::read(&path) { | ||
| 128 | let _ = zip.start_file(&*name.to_string_lossy(), options); | ||
| 129 | let _ = std::io::Write::write_all(&mut zip, &contents); | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | } | ||
| 135 | let _ = zip.finish(); | ||
| 136 | } | ||
| 137 | |||
| 138 | let conn = state.db.conn(); | ||
| 139 | conn.execute( | ||
| 140 | "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", | ||
| 141 | params![instance_id, filename], | ||
| 142 | ) | ||
| 143 | .ok(); | ||
| 144 | drop(conn); | ||
| 145 | |||
| 146 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) | ||
| 147 | } | ||
| 148 | |||
| 149 | async fn restore_backup( | ||
| 150 | State(state): State<AppState>, | ||
| 151 | Path(backup_id): Path<i64>, | ||
| 152 | CurrentUser(_user): CurrentUser, | ||
| 153 | ) -> Result<Redirect, axum::response::Redirect> { | ||
| 154 | let conn = state.db.conn(); | ||
| 155 | let (instance_id, _filename): (i64, String) = conn | ||
| 156 | .query_row( | ||
| 157 | "SELECT instance_id, filename FROM backups WHERE id = ?1", | ||
| 158 | params![backup_id], | ||
| 159 | |row| Ok((row.get(0)?, row.get(1)?)), | ||
| 160 | ) | ||
| 161 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 162 | drop(conn); | ||
| 163 | |||
| 164 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) | ||
| 165 | } | ||
| 166 | |||
| 167 | async fn download_backup( | ||
| 168 | State(state): State<AppState>, | ||
| 169 | Path(backup_id): Path<i64>, | ||
| 170 | CurrentUser(_user): CurrentUser, | ||
| 171 | ) -> Result<Response, (StatusCode, &'static str)> { | ||
| 172 | let conn = state.db.conn(); | ||
| 173 | let (_, filename): (i64, String) = conn | ||
| 174 | .query_row( | ||
| 175 | "SELECT instance_id, filename FROM backups WHERE id = ?1", | ||
| 176 | params![backup_id], | ||
| 177 | |row| Ok((row.get(0)?, row.get(1)?)), | ||
| 178 | ) | ||
| 179 | .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; | ||
| 180 | drop(conn); | ||
| 181 | |||
| 182 | let backup_path = state | ||
| 183 | .config | ||
| 184 | .server_directory | ||
| 185 | .join("backups") | ||
| 186 | .join(&filename); | ||
| 187 | |||
| 188 | let content = | ||
| 189 | std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; | ||
| 190 | |||
| 191 | Ok(Response::builder() | ||
| 192 | .header(header::CONTENT_TYPE, "application/zip") | ||
| 193 | .header( | ||
| 194 | header::CONTENT_DISPOSITION, | ||
| 195 | format!("attachment; filename=\"{filename}\""), | ||
| 196 | ) | ||
| 197 | .body(Body::from(content)) | ||
| 198 | .unwrap()) | ||
| 199 | } | ||
| 200 | |||
| 201 | async fn export_world( | ||
| 202 | State(state): State<AppState>, | ||
| 203 | Path(instance_id): Path<i64>, | ||
| 204 | CurrentUser(_user): CurrentUser, | ||
| 205 | ) -> Result<Response, (StatusCode, &'static str)> { | ||
| 206 | let conn = state.db.conn(); | ||
| 207 | let instance_name: String = conn | ||
| 208 | .query_row( | ||
| 209 | "SELECT name FROM instances WHERE id = ?1", | ||
| 210 | params![instance_id], | ||
| 211 | |row| row.get(0), | ||
| 212 | ) | ||
| 213 | .map_err(|_| (StatusCode::NOT_FOUND, "Instance not found"))?; | ||
| 214 | drop(conn); | ||
| 215 | |||
| 216 | let world_dir = state | ||
| 217 | .config | ||
| 218 | .server_directory | ||
| 219 | .join("instances") | ||
| 220 | .join(&instance_name) | ||
| 221 | .join("data") | ||
| 222 | .join("world"); | ||
| 223 | |||
| 224 | let mut zip_buf = Vec::new(); | ||
| 225 | { | ||
| 226 | let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); | ||
| 227 | let options: FileOptions<'_, ()> = | ||
| 228 | FileOptions::default().compression_method(zip::CompressionMethod::Deflated); | ||
| 229 | |||
| 230 | if let Ok(entries) = std::fs::read_dir(&world_dir) { | ||
| 231 | for entry in entries.flatten() { | ||
| 232 | let path = entry.path(); | ||
| 233 | if path.is_file() { | ||
| 234 | if let Some(name) = path.file_name() { | ||
| 235 | if let Ok(contents) = std::fs::read(&path) { | ||
| 236 | let _ = zip.start_file(&*name.to_string_lossy(), options); | ||
| 237 | let _ = std::io::Write::write_all(&mut zip, &contents); | ||
| 238 | } | ||
| 239 | } | ||
| 240 | } | ||
| 241 | } | ||
| 242 | } | ||
| 243 | let _ = zip.finish(); | ||
| 244 | } | ||
| 245 | |||
| 246 | let filename = format!("{instance_name}_world.zip"); | ||
| 247 | |||
| 248 | Ok(Response::builder() | ||
| 249 | .header(header::CONTENT_TYPE, "application/zip") | ||
| 250 | .header( | ||
| 251 | header::CONTENT_DISPOSITION, | ||
| 252 | format!("attachment; filename=\"{filename}\""), | ||
| 253 | ) | ||
| 254 | .body(Body::from(zip_buf)) | ||
| 255 | .unwrap()) | ||
| 256 | } | ||
