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,7 +15,9 @@ export const MAX_RECIPE_BYTES = 256 * 1024;
|
||||
export const MAX_PHOTO_BYTES = 12 * 1024 * 1024;
|
||||
export const MAX_PHOTOS_PER_USER = 12;
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
// Everything the deployment owns lives here: the SQLite file and the two media
|
||||
// folders. Exported because the backup/restore routes walk the same root.
|
||||
export const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
// Uploaded originals. Filenames are server-generated hex — a user filename
|
||||
|
||||
@@ -2,10 +2,13 @@ import { recipeFile } from './recipeFile';
|
||||
import { sendVerificationMail } from './mailer';
|
||||
import { placeName } from './place';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import {
|
||||
DATA_DIR,
|
||||
MAX_PHOTO_BYTES,
|
||||
MAX_PHOTOS_PER_USER,
|
||||
MAX_RECIPE_BYTES,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
createRecipe,
|
||||
createSession,
|
||||
createUser,
|
||||
db,
|
||||
deleteAllPhotos,
|
||||
deletePhoto,
|
||||
deletePhotoOf,
|
||||
@@ -130,6 +134,14 @@ app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: '
|
||||
done(null, body);
|
||||
});
|
||||
|
||||
// A restore body is a whole data dir, so it is the one body far past the
|
||||
// instance limit. Buffered, not streamed: `tar` needs the archive to be
|
||||
// seekable, and the route is admin-gated (see gateAdmin) before a byte is read.
|
||||
const RESTORE_LIMIT = 4 * 1024 * 1024 * 1024;
|
||||
app.addContentTypeParser('application/gzip', { parseAs: 'buffer', bodyLimit: RESTORE_LIMIT }, (_req, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
|
||||
// ---- rate limiting --------------------------------------------------------
|
||||
// Fixed window keyed on what the caller is trying to abuse — an email, or a
|
||||
// user id — rather than an address: the API sits behind two proxies, so a
|
||||
@@ -1085,6 +1097,118 @@ app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply
|
||||
return reply.status(200).send({ id, slots: set });
|
||||
});
|
||||
|
||||
// ---- backup / restore -----------------------------------------------------
|
||||
// The deployment's whole state is DATA_DIR: one SQLite file plus the two media
|
||||
// folders. The archive is a plain tar.gz of exactly those three, which makes
|
||||
// one artefact serve both jobs — the operator's backup, and the data package
|
||||
// that moves an install onto another box (the restore route below takes the
|
||||
// very same file).
|
||||
const BACKUP_PATHS = ['recipescam.db', 'uploads', 'avatars'];
|
||||
|
||||
// Sortable and filename-safe, so two backups in one day never collide.
|
||||
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
|
||||
// Authenticate before the body is read. Every other route can afford to parse
|
||||
// first and refuse after; the restore route cannot, because a rejected caller
|
||||
// would already have cost the process gigabytes of memory.
|
||||
function gateAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
|
||||
const user = admin(req);
|
||||
if ('status' in user) {
|
||||
reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin only: download the data dir. The database is written to while the
|
||||
// archive streams, so it is snapshotted through SQLite's own backup rather than
|
||||
// copied — a plain cp could catch a half-committed page. The media folders are
|
||||
// append-only and are tarred straight off the volume, so no second copy of them
|
||||
// is ever made.
|
||||
app.get('/api/admin/backup', async (req, reply) => {
|
||||
const user = admin(req);
|
||||
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'rc-backup-'));
|
||||
try {
|
||||
await db.backup(join(tmp, 'recipescam.db'));
|
||||
} catch (err) {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ error: 'could not snapshot the database' });
|
||||
}
|
||||
const tar = spawn('tar', ['-czf', '-', '-C', tmp, 'recipescam.db', '-C', DATA_DIR, 'uploads', 'avatars']);
|
||||
tar.on('close', () => rmSync(tmp, { recursive: true, force: true }));
|
||||
tar.on('error', (err) => {
|
||||
req.log.error(err);
|
||||
tar.stdout.destroy();
|
||||
});
|
||||
tar.stderr.on('data', (chunk) => req.log.error(`backup tar: ${String(chunk).trim()}`));
|
||||
reply.header('content-type', 'application/gzip');
|
||||
reply.header('content-disposition', `attachment; filename="recipescam-backup-${stamp()}.tar.gz"`);
|
||||
return reply.send(tar.stdout);
|
||||
});
|
||||
|
||||
// Admin only: put a backup back. The body is the archive itself (see the gzip
|
||||
// parser above), and it replaces the data on disk — so the current state is
|
||||
// tarred aside first, and the process then exits: `restart: unless-stopped`
|
||||
// brings it back up on the restored files, which is the only moment the open
|
||||
// SQLite handle can be dropped safely.
|
||||
app.post(
|
||||
'/api/admin/restore',
|
||||
{
|
||||
bodyLimit: RESTORE_LIMIT,
|
||||
onRequest: (req, reply, done) => {
|
||||
if (gateAdmin(req, reply)) done();
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const body = req.body as unknown;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'empty body' });
|
||||
// Unpacked beside the data it is about to replace, not in /tmp: the media
|
||||
// folders are hundreds of megabytes, and this keeps the swap on one
|
||||
// filesystem. The name is dotted so it can never show up as a media folder.
|
||||
const tmp = join(DATA_DIR, `.restore-${Date.now()}`);
|
||||
try {
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
const archive = join(tmp, 'in.tar.gz');
|
||||
writeFileSync(archive, body);
|
||||
// An archive is untrusted input even when an admin sent it: a `..` entry
|
||||
// would write anywhere in the container. The listing is checked before
|
||||
// anything is unpacked, and tar itself refuses absolute names.
|
||||
const listed = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
if (listed.status !== 0) return reply.status(400).send({ error: 'not a readable .tar.gz' });
|
||||
const unsafe = listed.stdout.split('\n').some((name) => name.startsWith('/') || name.split('/').includes('..'));
|
||||
if (unsafe) return reply.status(400).send({ error: 'archive has unsafe paths' });
|
||||
const unpacked = spawnSync('tar', ['-xzf', archive, '-C', tmp, '--no-same-owner'], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (unpacked.status !== 0 || !existsSync(join(tmp, 'recipescam.db')))
|
||||
return reply.status(400).send({ error: 'archive has no recipescam.db' });
|
||||
// Close before the swap: a checkpoint on close is what makes the safety
|
||||
// copy below a complete database rather than one missing its WAL.
|
||||
db.close();
|
||||
spawnSync('tar', ['-czf', join(DATA_DIR, `.pre-restore-${stamp()}.tar.gz`), '-C', DATA_DIR, ...BACKUP_PATHS], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
for (const dir of ['uploads', 'avatars']) {
|
||||
const from = join(tmp, dir);
|
||||
rmSync(join(DATA_DIR, dir), { recursive: true, force: true });
|
||||
mkdirSync(join(DATA_DIR, dir), { recursive: true });
|
||||
if (existsSync(from)) cpSync(from, join(DATA_DIR, dir), { recursive: true });
|
||||
}
|
||||
cpSync(join(tmp, 'recipescam.db'), join(DATA_DIR, 'recipescam.db'));
|
||||
// Whatever the outgoing database left behind must not be replayed onto the
|
||||
// incoming one.
|
||||
for (const suffix of ['-wal', '-shm']) rmSync(join(DATA_DIR, `recipescam.db${suffix}`), { force: true });
|
||||
setTimeout(() => process.exit(0), 250);
|
||||
return reply.status(200).send({ ok: true, restarting: true });
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app
|
||||
.listen({ port: PORT, host: HOST })
|
||||
.catch((err) => {
|
||||
|
||||
@@ -51,6 +51,26 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The one route that carries a whole data dir back in (see /api/admin/restore
|
||||
# — who may call it is the API's own admin check, done before it reads a byte).
|
||||
# The upload cap that holds everywhere else would reject it, and unpacking the
|
||||
# archive plus the restart takes longer than the stock read timeout. nginx
|
||||
# takes the longest matching prefix, so this one holds for that route while
|
||||
# /api/ below keeps the rest.
|
||||
location /api/admin/restore {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $api_upstream http://api:3000;
|
||||
proxy_pass $api_upstream$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
client_max_body_size 0;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
# Resolved per request through Docker's embedded DNS, so the frontend can
|
||||
# start before the API container without nginx refusing to boot.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -282,6 +282,15 @@ export const en: Dict = {
|
||||
'adm.deleteSelected': 'DELETE SELECTED ({n})',
|
||||
'adm.userDeleteSelectedConfirm': 'Permanently delete the {n} selected accounts with all of their photos and recipes? This cannot be undone.',
|
||||
'adm.tabStats': 'STATS',
|
||||
'adm.tabBackup': 'BACKUP',
|
||||
'adm.backupHint': 'Download the whole system state — the accounts/recipes database and both media folders — as one .tar.gz. That same file is the data package which moves this install to another machine: restore with it.',
|
||||
'adm.backupSave': 'DOWNLOAD BACKUP',
|
||||
'adm.restorePick': 'RESTORE FROM FILE…',
|
||||
'adm.restoreHint': 'A restore replaces all current data with the file’s, then the API restarts itself. The data being replaced is archived aside in the data folder first.',
|
||||
'adm.restoreConfirm': 'Replace all current data with {file}? This cannot be undone. A safety archive is still kept in the data folder.',
|
||||
'adm.restoreRunning': 'Restoring — the API is restarting…',
|
||||
'adm.restoreDone': 'Restore finished.',
|
||||
'adm.restoreSlow': 'The API never answered. Check the container log, then reload.',
|
||||
|
||||
'stats.days': '{n} DAYS',
|
||||
'stats.hint': 'Traffic over the selected range.',
|
||||
|
||||
@@ -286,6 +286,15 @@ export const vi = {
|
||||
'adm.deleteSelected': 'XOÁ ĐÃ CHỌN ({n})',
|
||||
'adm.userDeleteSelectedConfirm': 'Xoá vĩnh viễn {n} tài khoản đã chọn cùng toàn bộ ảnh và công thức của chúng? Không hoàn tác được.',
|
||||
'adm.tabStats': 'THỐNG KÊ',
|
||||
'adm.tabBackup': 'SAO LƯU',
|
||||
'adm.backupHint': 'Tải toàn bộ dữ liệu của hệ thống — cơ sở dữ liệu tài khoản/công thức và hai thư mục ảnh — thành một tệp .tar.gz. Chính tệp này là gói dữ liệu để chuyển sang máy khác: khôi phục bằng nó.',
|
||||
'adm.backupSave': 'TẢI BẢN SAO LƯU',
|
||||
'adm.restorePick': 'KHÔI PHỤC TỪ TỆP…',
|
||||
'adm.restoreHint': 'Khôi phục thay toàn bộ dữ liệu hiện tại bằng dữ liệu trong tệp, sau đó API tự khởi động lại. Dữ liệu đang chạy được nén lại một bản an toàn trong thư mục data trước khi thay.',
|
||||
'adm.restoreConfirm': 'Thay toàn bộ dữ liệu hiện tại bằng tệp {file}? Không hoàn tác được. Một bản an toàn vẫn được giữ trong thư mục data.',
|
||||
'adm.restoreRunning': 'Đang khôi phục — API đang khởi động lại…',
|
||||
'adm.restoreDone': 'Đã khôi phục xong.',
|
||||
'adm.restoreSlow': 'API không trả lời lại. Kiểm tra nhật ký container rồi tải lại trang.',
|
||||
|
||||
// The traffic screen. Bucket names ('/app', a control's data-key, a browser
|
||||
// version) are machine values and stay as they are.
|
||||
|
||||
Reference in New Issue
Block a user