refactor(account): simplify backup/restore with native download link

Replace the blob URL download JS with a Content-Disposition header on
the export endpoint and a plain <a> tag. Consolidate restore JS from
event listeners into a single global function.
This commit is contained in:
Jon Seager 2026-02-05 12:27:17 +00:00
parent 9eb9a9a56c
commit 149921dc31
No known key found for this signature in database
2 changed files with 72 additions and 103 deletions

View file

@ -1,6 +1,6 @@
use axum::Json; use axum::Json;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
@ -9,16 +9,37 @@ use crate::application::server::AppState;
use crate::infrastructure::backup::BackupData; use crate::infrastructure::backup::BackupData;
/// GET /api/v1/backup — export all data as JSON (requires authentication) /// 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( pub(crate) async fn export_backup(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
) -> Result<Json<BackupData>, ApiError> { ) -> Result<Response, ApiError> {
let data = state let data = state
.backup_service .backup_service
.export() .export()
.await .await
.map_err(|e| AppError::unexpected(e.to_string()))?; .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) /// POST /api/v1/backup/restore — restore from JSON backup (requires authentication)

View file

@ -190,23 +190,24 @@
<p class="text-sm text-stone-500 mb-3">Export all coffee data as JSON, or restore from a previous backup.</p> <p class="text-sm text-stone-500 mb-3">Export all coffee data as JSON, or restore from a previous backup.</p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<button <a
id="download-backup-btn" href="/api/v1/backup"
type="button" download
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 inline-block"
> >
Download Backup Download Backup
</button> </a>
<button <button
id="restore-backup-btn"
type="button" type="button"
class="rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-50 hover:text-stone-800" class="rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-50 hover:text-stone-800"
onclick="document.getElementById('restore-file-input').click()"
> >
Restore from Backup Restore from Backup
</button> </button>
</div> </div>
<input type="file" id="restore-file-input" accept=".json" class="hidden" /> <input type="file" id="restore-file-input" accept=".json" class="hidden"
onchange="restoreFromFile(this)" />
<div id="backup-status" class="mt-3 hidden rounded-lg border border-green-300 bg-green-50 p-4 text-sm text-green-800"></div> <div id="backup-status" class="mt-3 hidden rounded-lg border border-green-300 bg-green-50 p-4 text-sm text-green-800"></div>
<div id="backup-error" class="mt-3 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div> <div id="backup-error" class="mt-3 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
@ -379,73 +380,27 @@
window.location.reload(); 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 () { // --- Global functions for inline onclick/onchange handlers ---
restoreFileInput.click();
});
restoreFileInput.addEventListener("change", async function () { async function restoreFromFile(input) {
const file = restoreFileInput.files[0]; const file = input.files[0];
if (!file) return; if (!file) return;
restoreFileInput.value = ""; input.value = "";
if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) { if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) {
return; return;
} }
backupStatus.classList.add("hidden"); const status = document.getElementById("backup-status");
backupError.classList.add("hidden"); const error = document.getElementById("backup-error");
restoreBackupBtn.disabled = true; status.classList.add("hidden");
error.classList.add("hidden");
try { try {
const text = await file.text(); const text = await file.text();
JSON.parse(text); // validate JSON before sending JSON.parse(text);
const response = await fetch("/api/v1/backup/restore", { const response = await fetch("/api/v1/backup/restore", {
method: "POST", method: "POST",
@ -460,20 +415,13 @@
throw new Error("Restore failed (HTTP " + response.status + ")."); throw new Error("Restore failed (HTTP " + response.status + ").");
} }
showBackupStatus("Backup restored successfully."); status.textContent = "Backup restored successfully.";
status.classList.remove("hidden");
} catch (err) { } catch (err) {
if (err instanceof SyntaxError) { error.textContent = err instanceof SyntaxError ? "Invalid JSON file." : err.message;
showBackupError("Invalid JSON file."); error.classList.remove("hidden");
} else {
showBackupError(err.message);
} }
} finally {
restoreBackupBtn.disabled = false;
} }
});
});
// --- Global functions for inline onclick handlers ---
async function deletePasskey(id, name) { async function deletePasskey(id, name) {
if (!confirm('Delete passkey "' + name + '"? This cannot be undone.')) return; if (!confirm('Delete passkey "' + name + '"? This cannot be undone.')) return;