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:
@@ -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) }),
|
||||
|
||||
Reference in New Issue
Block a user