feat(release): publish minimal self-contained Magent source

This commit is contained in:
Magent release tooling
2026-09-19 16:58:12 +12:00
commit 5fa5d45535
272 changed files with 79305 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
"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&apos;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>
);
}