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:
2026-09-25 08:46:29 +07:00
parent 9016ef038d
commit d2115941c7
7 changed files with 252 additions and 4 deletions
+19
View File
@@ -314,6 +314,25 @@ export const api = {
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
// The whole data dir (SQLite + media) as one tar.gz, and the route that takes
// the same file back. The download is a plain link — the session cookie rides
// along — so nothing here fetches it.
adminBackupUrl: () => '/api/admin/backup',
// Raw bytes as the body, like a photo upload: the archive is already
// compressed, so no multipart wrapper and nothing to re-encode. The API
// replaces its data and restarts itself, so a 200 means "come back shortly".
adminRestore: async (file: File) => {
const res = await fetch('/api/admin/restore', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/gzip' },
body: file,
});
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { ok: boolean };
},
// Own profile. The API wants `currentPassword` on every edit, even an email-only one.
updateProfile: (body: { email?: string; password?: string; currentPassword: string }) =>
call<{ user: User }>('/auth/me', { method: 'PATCH', body: JSON.stringify(body) }),