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:
parent
9eb9a9a56c
commit
149921dc31
2 changed files with 72 additions and 103 deletions
|
|
@ -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<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
) -> Result<Json<BackupData>, ApiError> {
|
||||
) -> Result<Response, ApiError> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
id="download-backup-btn"
|
||||
type="button"
|
||||
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"
|
||||
<a
|
||||
href="/api/v1/backup"
|
||||
download
|
||||
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
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
id="restore-backup-btn"
|
||||
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"
|
||||
onclick="document.getElementById('restore-file-input').click()"
|
||||
>
|
||||
Restore from Backup
|
||||
</button>
|
||||
</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-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();
|
||||
});
|
||||
|
||||
// --- 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();
|
||||
});
|
||||
// --- Global functions for inline onclick/onchange handlers ---
|
||||
|
||||
restoreFileInput.addEventListener("change", async function () {
|
||||
const file = restoreFileInput.files[0];
|
||||
async function restoreFromFile(input) {
|
||||
const file = input.files[0];
|
||||
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.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
backupStatus.classList.add("hidden");
|
||||
backupError.classList.add("hidden");
|
||||
restoreBackupBtn.disabled = true;
|
||||
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); // validate JSON before sending
|
||||
JSON.parse(text);
|
||||
|
||||
const response = await fetch("/api/v1/backup/restore", {
|
||||
method: "POST",
|
||||
|
|
@ -460,20 +415,13 @@
|
|||
throw new Error("Restore failed (HTTP " + response.status + ").");
|
||||
}
|
||||
|
||||
showBackupStatus("Backup restored successfully.");
|
||||
status.textContent = "Backup restored successfully.";
|
||||
status.classList.remove("hidden");
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
showBackupError("Invalid JSON file.");
|
||||
} else {
|
||||
showBackupError(err.message);
|
||||
error.textContent = err instanceof SyntaxError ? "Invalid JSON file." : err.message;
|
||||
error.classList.remove("hidden");
|
||||
}
|
||||
} finally {
|
||||
restoreBackupBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Global functions for inline onclick handlers ---
|
||||
|
||||
async function deletePasskey(id, name) {
|
||||
if (!confirm('Delete passkey "' + name + '"? This cannot be undone.')) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue