feat(admin): back the data up, and put it back, from the admin tool
A new BACKUP tab downloads the deployment's whole state — the SQLite file and both media folders, photos included — as one .tar.gz, and takes the same file back. That one artefact therefore does both jobs: the operator's backup and the data package that moves an install onto another box. The database is snapshotted through SQLite's own backup rather than copied, because the file is written to while the archive streams; the media folders are tarred straight off the volume, so no second copy of them is made. A restore replaces the data on disk and then exits — the container's restart policy brings the API back on the restored files, which is the only moment the open handle can be dropped. The state being replaced is tarred aside first, and the archive is checked for `..` entries before anything is unpacked. The API authenticates that route before it reads a byte, and nginx lets that one path past the body cap which holds everywhere else.
This commit is contained in:
@@ -15,13 +15,15 @@ import type { MsgKey } from './i18n/vi';
|
||||
// section. Every frame carries its own labels, section boxes,
|
||||
// QR code and delete button under its preview
|
||||
// Stats — the visitor counter: views, clicks and their breakdowns
|
||||
// Backup — the data dir out as one .tar.gz, and the route that puts a
|
||||
// .tar.gz back (the API replaces its data and restarts)
|
||||
// Close — leaves the frame and goes back to the landing page
|
||||
// The frame holds no privilege of its own — the API answers 403 unless the
|
||||
// signed-in account is on the ADMIN_EMAILS allowlist, so this is only a viewer.
|
||||
// ponytail: no pagination. The upload quota caps the table at a handful of
|
||||
// rows per account; add a page cursor when the strip outgrows one screen.
|
||||
type State = 'loading' | 'guest' | 'forbidden' | 'ready';
|
||||
type Tab = 'profile' | 'users' | 'pictures' | 'stats';
|
||||
type Tab = 'profile' | 'users' | 'pictures' | 'stats' | 'backup';
|
||||
|
||||
// Where a photo can be put. The boxes are independent — a photo may sit in all
|
||||
// three sections at once, and each section draws one random photo per visit out
|
||||
@@ -44,6 +46,7 @@ const MENU: { id: Tab; key: MsgKey }[] = [
|
||||
{ id: 'users', key: 'adm.tabUsers' },
|
||||
{ id: 'pictures', key: 'adm.tabPictures' },
|
||||
{ id: 'stats', key: 'adm.tabStats' },
|
||||
{ id: 'backup', key: 'adm.tabBackup' },
|
||||
];
|
||||
|
||||
export function Admin() {
|
||||
@@ -67,6 +70,7 @@ export function Admin() {
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<number[]>([]);
|
||||
const filePick = useRef<HTMLInputElement>(null);
|
||||
const restorePick = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const me = await api.me().catch(() => null);
|
||||
@@ -148,6 +152,31 @@ export function Admin() {
|
||||
});
|
||||
};
|
||||
|
||||
// The one action here that replaces everything. The API swaps its data on
|
||||
// disk and exits; the container's restart policy brings it back. So the page
|
||||
// waits for the API to answer again instead of showing a listing that is no
|
||||
// longer what the server holds — and the wait is bounded, because a restore
|
||||
// that never comes back is a thing the operator needs told.
|
||||
const restore = (file: File) => {
|
||||
if (!window.confirm(t('adm.restoreConfirm', { file: file.name }))) return;
|
||||
return run(async () => {
|
||||
await api.adminRestore(file);
|
||||
setNote(t('adm.restoreRunning'));
|
||||
for (let i = 0; i < 90; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
try {
|
||||
await api.me();
|
||||
await refreshPhotos();
|
||||
setNote(t('adm.restoreDone'));
|
||||
return;
|
||||
} catch {
|
||||
// Still down for its restart; keep waiting.
|
||||
}
|
||||
}
|
||||
setNote(t('adm.restoreSlow'));
|
||||
});
|
||||
};
|
||||
|
||||
// From the users table: open that account's album in the left column.
|
||||
const showOwner = (userId: number) => {
|
||||
setTab('pictures');
|
||||
@@ -673,6 +702,42 @@ export function Admin() {
|
||||
{tab === 'profile' ? <Profile onSaved={(msg) => setNote(msg)} /> : null}
|
||||
|
||||
{tab === 'stats' ? <Stats /> : null}
|
||||
|
||||
{tab === 'backup' ? (
|
||||
<>
|
||||
<p className="hint adm-sub">{t('adm.backupHint')}</p>
|
||||
<div className="adm-bulk">
|
||||
{/* A plain link, so the browser does the download and the
|
||||
session cookie authorises it — nothing here buffers a
|
||||
multi-hundred-megabyte archive in memory. */}
|
||||
<a className="btn primary" data-key="adm-backup-save" href={api.adminBackupUrl()}>
|
||||
{t('adm.backupSave')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key="adm-restore-pick"
|
||||
disabled={busy}
|
||||
onClick={() => restorePick.current?.click()}
|
||||
>
|
||||
{busy ? t('auth.busy') : t('adm.restorePick')}
|
||||
</button>
|
||||
<input
|
||||
ref={restorePick}
|
||||
type="file"
|
||||
hidden
|
||||
accept=".gz,application/gzip"
|
||||
data-key="adm-restore-input"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) void restore(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="hint adm-sub">{t('adm.restoreHint')}</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user