396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiUrl, requestJson } from "../../lib/api-client";
|
|
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
|
|
import AdminShell from "../../ui/AdminShell";
|
|
import styles from "./backups.module.css";
|
|
|
|
type BackupDetails = {
|
|
created_at: string;
|
|
build: string;
|
|
include_cache: boolean;
|
|
};
|
|
|
|
type BackupStatus = {
|
|
format_version: number;
|
|
max_upload_bytes: number;
|
|
max_expanded_bytes: number;
|
|
include_cache_default: boolean;
|
|
pending_restore: (BackupDetails & { staged_at: string }) | null;
|
|
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
|
|
};
|
|
|
|
type RestoreResult = {
|
|
status: "staged";
|
|
restart_required: true;
|
|
backup: BackupDetails;
|
|
message: string;
|
|
};
|
|
|
|
const dateLabel = (value: string) => {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
};
|
|
|
|
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
|
|
|
|
export default function BackupsPage() {
|
|
const router = useRouter();
|
|
const [data, setData] = useState<BackupStatus | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [revision, setRevision] = useState(0);
|
|
const [error, setError] = useState("");
|
|
const [notice, setNotice] = useState("");
|
|
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
|
|
const [includeCache, setIncludeCache] = useState(false);
|
|
const [exportPassphrase, setExportPassphrase] = useState("");
|
|
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
|
const [restorePassphrase, setRestorePassphrase] = useState("");
|
|
const [confirmation, setConfirmation] = useState("");
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
|
|
const handleError = useCallback(
|
|
(cause: unknown, fallback: string) => {
|
|
if (cause instanceof UnauthorizedError) {
|
|
router.replace("/login?next=%2Fadmin%2Fbackups");
|
|
return;
|
|
}
|
|
if (cause instanceof ForbiddenError) {
|
|
router.replace("/");
|
|
return;
|
|
}
|
|
setError(cause instanceof Error ? cause.message : fallback);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void revision;
|
|
const abort = new AbortController();
|
|
setLoading(true);
|
|
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
|
|
.then((result) => {
|
|
if (abort.signal.aborted) return;
|
|
setData(result);
|
|
setIncludeCache(result.include_cache_default);
|
|
})
|
|
.catch((cause: unknown) => {
|
|
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
|
|
})
|
|
.finally(() => {
|
|
if (!abort.signal.aborted) setLoading(false);
|
|
});
|
|
return () => abort.abort();
|
|
}, [revision, handleError]);
|
|
|
|
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (busy) return;
|
|
setError("");
|
|
setNotice("");
|
|
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
|
|
setError("Choose a backup passphrase between 12 and 1,024 characters.");
|
|
return;
|
|
}
|
|
if (exportPassphrase !== confirmPassphrase) {
|
|
setError("The backup passphrases do not match.");
|
|
return;
|
|
}
|
|
setBusy("export");
|
|
try {
|
|
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
|
|
});
|
|
if (!response.ok) {
|
|
const payload: unknown = await response.json().catch(() => null);
|
|
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
|
|
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
|
|
}
|
|
const blob = await response.blob();
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
|
|
link.href = downloadUrl;
|
|
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
|
|
link.hidden = true;
|
|
document.body.appendChild(link);
|
|
try {
|
|
link.click();
|
|
} finally {
|
|
link.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
|
|
}
|
|
setExportPassphrase("");
|
|
setConfirmPassphrase("");
|
|
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
|
|
} catch (cause) {
|
|
handleError(cause, "Could not create the backup.");
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (busy || !data || data.pending_restore) return;
|
|
setError("");
|
|
setNotice("");
|
|
if (!file || file.size === 0) {
|
|
setError("Choose a Magent backup file to restore.");
|
|
return;
|
|
}
|
|
if (file.size > data.max_upload_bytes) {
|
|
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
|
|
return;
|
|
}
|
|
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
|
|
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
|
|
return;
|
|
}
|
|
if (confirmation !== "RESTORE") {
|
|
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
|
|
return;
|
|
}
|
|
setBusy("restore");
|
|
try {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
form.append("passphrase", restorePassphrase);
|
|
form.append("confirmation", confirmation);
|
|
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
|
|
setData((current) =>
|
|
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
|
|
);
|
|
setRestorePassphrase("");
|
|
setConfirmation("");
|
|
setFile(null);
|
|
if (fileInput.current) fileInput.current.value = "";
|
|
setNotice(
|
|
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
|
|
);
|
|
} catch (cause) {
|
|
handleError(cause, "Could not prepare the restore.");
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const cancelRestore = async () => {
|
|
if (busy) return;
|
|
setBusy("cancel");
|
|
setError("");
|
|
setNotice("");
|
|
try {
|
|
await requestJson("/admin/backups/restore", { method: "DELETE" });
|
|
setData((current) => (current ? { ...current, pending_restore: null } : current));
|
|
setNotice("Pending restore cancelled. Your current data is unchanged.");
|
|
} catch (cause) {
|
|
handleError(cause, "Could not cancel the restore.");
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
|
|
<div className={styles.page}>
|
|
{error && (
|
|
<p className="error-banner" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{notice && (
|
|
<p className={styles.notice} role="status">
|
|
{notice}
|
|
</p>
|
|
)}
|
|
{loading && <p role="status">Loading backup settings...</p>}
|
|
{!loading && !data && (
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => {
|
|
setError("");
|
|
setRevision((current) => current + 1);
|
|
}}
|
|
>
|
|
Try again
|
|
</button>
|
|
)}
|
|
{data && (
|
|
<>
|
|
<section className={styles.summary} aria-labelledby="backup-contents">
|
|
<h2 id="backup-contents">What is saved</h2>
|
|
<p>
|
|
Every backup includes Magent settings, app connection credentials, branding, and the complete database
|
|
with accounts, invites, requests, and cached records. You can also include downloaded artwork caches.
|
|
</p>
|
|
<p>
|
|
Connected apps and media files need their own backups. App credentials configured through the
|
|
environment are included, but the deployment environment file, host paths, and signing or encryption
|
|
keys are not.
|
|
</p>
|
|
{data.last_restore && (
|
|
<p className={styles.muted}>
|
|
{data.last_restore.status === "rolled_back"
|
|
? "Last restore was rolled back"
|
|
: "Last restore completed"}
|
|
: {dateLabel(data.last_restore.restored_at)}.
|
|
{data.last_restore.status === "rolled_back" &&
|
|
` ${data.last_restore.message || "The previous data was recovered automatically."}`}
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
{data.pending_restore && (
|
|
<section className={styles.pending} aria-labelledby="pending-restore-title">
|
|
<h2 id="pending-restore-title">Restore ready — restart required</h2>
|
|
<p>
|
|
Backup from {dateLabel(data.pending_restore.created_at)}
|
|
{data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "}
|
|
{data.pending_restore.include_cache ? "included" : "not included"}.
|
|
</p>
|
|
<p>
|
|
Restart the Magent container or service to apply this backup. Changes made since the backup was
|
|
created will be replaced. Afterwards, sign in again with an administrator account from the restored
|
|
backup.
|
|
</p>
|
|
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
|
|
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
<div className={styles.columns}>
|
|
<section className={styles.panel} aria-labelledby="create-backup-title">
|
|
<h2 id="create-backup-title">Create a backup</h2>
|
|
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
|
|
<p>
|
|
Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "}
|
|
{sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave
|
|
artwork caches unchecked.
|
|
</p>
|
|
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
|
|
<fieldset className={styles.fields} disabled={!!busy}>
|
|
<legend className={styles.legend}>Backup options</legend>
|
|
<label className={styles.checkbox}>
|
|
<input
|
|
type="checkbox"
|
|
checked={includeCache}
|
|
onChange={(event) => setIncludeCache(event.target.checked)}
|
|
aria-describedby="cache-help"
|
|
/>
|
|
Include artwork caches
|
|
</label>
|
|
<p id="cache-help" className={styles.help}>
|
|
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
|
|
again. Database caches are always included.
|
|
</p>
|
|
<label className={styles.field}>
|
|
Backup passphrase
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={exportPassphrase}
|
|
onChange={(event) => setExportPassphrase(event.target.value)}
|
|
minLength={12}
|
|
maxLength={1024}
|
|
required
|
|
aria-describedby="backup-passphrase-help"
|
|
/>
|
|
</label>
|
|
<p id="backup-passphrase-help" className={styles.help}>
|
|
Use at least 12 characters. This passphrase is separate from your login password. A lost
|
|
passphrase cannot be recovered.
|
|
</p>
|
|
<label className={styles.field}>
|
|
Confirm backup passphrase
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={confirmPassphrase}
|
|
onChange={(event) => setConfirmPassphrase(event.target.value)}
|
|
minLength={12}
|
|
maxLength={1024}
|
|
required
|
|
/>
|
|
</label>
|
|
<button type="submit">
|
|
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
|
|
</button>
|
|
</fieldset>
|
|
</form>
|
|
</section>
|
|
|
|
<section className={styles.panel} aria-labelledby="restore-backup-title">
|
|
<h2 id="restore-backup-title">Restore a backup</h2>
|
|
<p>
|
|
Restoring replaces Magent's settings and database, including users and invites. Download a
|
|
current backup first if you want to keep these changes.
|
|
</p>
|
|
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
|
|
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
|
|
<legend className={styles.legend}>Choose and confirm a backup</legend>
|
|
<label className={styles.field}>
|
|
Backup file
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
accept=".magent-backup,application/octet-stream"
|
|
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
|
required
|
|
aria-describedby="backup-file-help"
|
|
/>
|
|
</label>
|
|
<p id="backup-file-help" className={styles.help}>
|
|
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
|
|
</p>
|
|
<label className={styles.field}>
|
|
Backup passphrase
|
|
<input
|
|
type="password"
|
|
autoComplete="off"
|
|
value={restorePassphrase}
|
|
onChange={(event) => setRestorePassphrase(event.target.value)}
|
|
minLength={12}
|
|
maxLength={1024}
|
|
required
|
|
/>
|
|
</label>
|
|
<label className={styles.field}>
|
|
Type RESTORE to confirm replacement
|
|
<input
|
|
type="text"
|
|
autoComplete="off"
|
|
autoCapitalize="characters"
|
|
spellCheck={false}
|
|
value={confirmation}
|
|
onChange={(event) => setConfirmation(event.target.value)}
|
|
pattern="RESTORE"
|
|
required
|
|
aria-describedby="restore-restart-help"
|
|
/>
|
|
</label>
|
|
<p id="restore-restart-help" className={styles.help}>
|
|
The backup is checked before being queued. It only takes effect when you restart Magent; you can
|
|
cancel before then. You will need to sign in using an account from the backup.
|
|
</p>
|
|
<button type="submit" className="danger-button">
|
|
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
|
|
</button>
|
|
</fieldset>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</AdminShell>
|
|
);
|
|
}
|