diff --git a/src/application/routes/backup.rs b/src/application/routes/backup.rs index c15bba2..6d5ddec 100644 --- a/src/application/routes/backup.rs +++ b/src/application/routes/backup.rs @@ -1,6 +1,6 @@ use axum::Json; use axum::extract::State; -use axum::http::StatusCode; +use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use crate::application::auth::AuthenticatedUser; @@ -9,16 +9,37 @@ use crate::application::server::AppState; use crate::infrastructure::backup::BackupData; /// GET /api/v1/backup — export all data as JSON (requires authentication) +/// +/// Returns the backup with a `Content-Disposition: attachment` header so +/// browsers trigger a file download while API/CLI consumers can ignore it. pub(crate) async fn export_backup( State(state): State, _auth_user: AuthenticatedUser, -) -> Result, ApiError> { +) -> Result { let data = state .backup_service .export() .await .map_err(|e| AppError::unexpected(e.to_string()))?; - Ok(Json(data)) + + let body = serde_json::to_string(&data).map_err(|e| AppError::unexpected(e.to_string()))?; + + let filename = format!( + "brewlog-backup-{}.json", + chrono::Utc::now().format("%Y-%m-%d") + ); + + Ok(( + [ + (header::CONTENT_TYPE, "application/json".to_string()), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{filename}\""), + ), + ], + body, + ) + .into_response()) } /// POST /api/v1/backup/restore — restore from JSON backup (requires authentication) diff --git a/templates/account.html b/templates/account.html index 270172b..271bf9b 100644 --- a/templates/account.html +++ b/templates/account.html @@ -190,23 +190,24 @@

Export all coffee data as JSON, or restore from a previous backup.

- +
- + @@ -379,101 +380,48 @@ window.location.reload(); }); - // --- Backup / Restore --- - const downloadBackupBtn = document.getElementById("download-backup-btn"); - const restoreBackupBtn = document.getElementById("restore-backup-btn"); - const restoreFileInput = document.getElementById("restore-file-input"); - const backupStatus = document.getElementById("backup-status"); - const backupError = document.getElementById("backup-error"); - - function showBackupStatus(message) { - backupError.classList.add("hidden"); - backupStatus.textContent = message; - backupStatus.classList.remove("hidden"); - } - - function showBackupError(message) { - backupStatus.classList.add("hidden"); - backupError.textContent = message; - backupError.classList.remove("hidden"); - } - - downloadBackupBtn.addEventListener("click", async function () { - downloadBackupBtn.disabled = true; - backupStatus.classList.add("hidden"); - backupError.classList.add("hidden"); - - try { - const response = await fetch("/api/v1/backup"); - if (!response.ok) { - throw new Error("Failed to download backup (HTTP " + response.status + ")."); - } - - const blob = await response.blob(); - const date = new Date().toISOString().slice(0, 10); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "brewlog-backup-" + date + ".json"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } catch (err) { - showBackupError(err.message); - } finally { - downloadBackupBtn.disabled = false; - } - }); - - restoreBackupBtn.addEventListener("click", function () { - restoreFileInput.click(); - }); - - restoreFileInput.addEventListener("change", async function () { - const file = restoreFileInput.files[0]; - if (!file) return; - restoreFileInput.value = ""; - - if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) { - return; - } - - backupStatus.classList.add("hidden"); - backupError.classList.add("hidden"); - restoreBackupBtn.disabled = true; - - try { - const text = await file.text(); - JSON.parse(text); // validate JSON before sending - - const response = await fetch("/api/v1/backup/restore", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: text, - }); - - if (response.status === 409) { - throw new Error("Database is not empty. Restore requires an empty database."); - } - if (!response.ok) { - throw new Error("Restore failed (HTTP " + response.status + ")."); - } - - showBackupStatus("Backup restored successfully."); - } catch (err) { - if (err instanceof SyntaxError) { - showBackupError("Invalid JSON file."); - } else { - showBackupError(err.message); - } - } finally { - restoreBackupBtn.disabled = false; - } - }); }); - // --- Global functions for inline onclick handlers --- + // --- Global functions for inline onclick/onchange handlers --- + + async function restoreFromFile(input) { + const file = input.files[0]; + if (!file) return; + input.value = ""; + + if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) { + return; + } + + const status = document.getElementById("backup-status"); + const error = document.getElementById("backup-error"); + status.classList.add("hidden"); + error.classList.add("hidden"); + + try { + const text = await file.text(); + JSON.parse(text); + + const response = await fetch("/api/v1/backup/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: text, + }); + + if (response.status === 409) { + throw new Error("Database is not empty. Restore requires an empty database."); + } + if (!response.ok) { + throw new Error("Restore failed (HTTP " + response.status + ")."); + } + + status.textContent = "Backup restored successfully."; + status.classList.remove("hidden"); + } catch (err) { + error.textContent = err instanceof SyntaxError ? "Invalid JSON file." : err.message; + error.classList.remove("hidden"); + } + } async function deletePasskey(id, name) { if (!confirm('Delete passkey "' + name + '"? This cannot be undone.')) return;