67 lines
6.5 KiB
JavaScript
67 lines
6.5 KiB
JavaScript
// Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz.
|
|
// Treat the file like a password: it contains the session secret and encrypted sign-in tokens.
|
|
// Restore (admin): upload that .tar.gz; it is checked, unpacked beside the data, and each top-level item is swapped in with the old one kept in .pre-restore-<date>; then the hub restarts.
|
|
// Export (anyone): your own data as a .tar.gz, without sign-in tokens.
|
|
const { spawn, execFile } = require('child_process');
|
|
const fs = require('fs'), path = require('path'), { pipeline } = require('stream/promises');
|
|
const users = require('./users');
|
|
const audit = require('./audit');
|
|
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
|
|
const MAX_RESTORE = 4 * 1024 ** 3;
|
|
const send = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
|
const run = (cmd, args) => new Promise((ok, no) => execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024 }, (e, out, err) => e ? no(new Error((String(err) || e.message).trim().slice(0, 300))) : ok(String(out))));
|
|
const rm = p => fs.rmSync(p, { recursive: true, force: true });
|
|
/** Refuse an archive that could write outside the data folder or plant links: every name must stay inside, and only plain files and folders are allowed. */
|
|
async function checkArchive(file) {
|
|
const names = (await run('tar', ['-tzf', file])).split('\n').filter(Boolean), long = (await run('tar', ['-tvzf', file])).split('\n').filter(Boolean);
|
|
if (!names.length) throw new Error('That backup is empty');
|
|
for (const n of names) if (path.isAbsolute(n) || n.split('/').includes('..')) throw new Error('That is not a backup this hub made (unsafe file names)');
|
|
for (const l of long) if (!/^[-d]/.test(l)) throw new Error('That is not a backup this hub made (it contains links or special files)');
|
|
return names;
|
|
}
|
|
async function restore(req, res, who) {
|
|
if (req.method !== 'POST') return send(res, 405, { error: 'Use POST' });
|
|
const stage = path.join(DATA_DIR, '.restore'), up = stage + '.tar.gz', out = path.join(stage, 'files'); rm(stage); rm(up);
|
|
try {
|
|
if ((+req.headers['content-length'] || 0) > MAX_RESTORE) return send(res, 413, { error: 'That file is too large' });
|
|
let n = 0; const cap = new (require('stream').Transform)({ transform(c, _e, cb) { n += c.length; cb(n > MAX_RESTORE ? Object.assign(new Error('That file is too large'), { status: 413 }) : null, c); } });
|
|
await pipeline(req, cap, fs.createWriteStream(up, { mode: 0o600 }));
|
|
await checkArchive(up); fs.mkdirSync(out, { recursive: true, mode: 0o700 }); await run('tar', ['-xzf', up, '--no-same-owner', '--no-same-permissions', '-C', out]);
|
|
const items = fs.readdirSync(out); if (!items.includes('secret.json') && !items.includes('users')) throw new Error('That does not look like an NE Hub backup (no accounts or secret in it)');
|
|
const keep = path.join(DATA_DIR, '.pre-restore-' + new Date().toISOString().replace(/[:.]/g, '-')); fs.mkdirSync(keep, { recursive: true, mode: 0o700 });
|
|
for (const it of items) { const dst = path.join(DATA_DIR, it); if (fs.existsSync(dst)) fs.renameSync(dst, path.join(keep, it)); fs.renameSync(path.join(out, it), dst); }
|
|
audit.log('backup restored', { who: who || '', items: items.length, kept: path.basename(keep) }, req);
|
|
send(res, 200, { ok: true, items: items.length, kept: path.basename(keep), restarting: true });
|
|
if (!process.env.NO_RESTART_AFTER_RESTORE) setTimeout(() => process.exit(0), 800); // the container restarts (restart: unless-stopped) and loads the restored data
|
|
} catch (e) { if (!res.headersSent) send(res, e.status || 400, { error: e.message }); }
|
|
finally { rm(stage); rm(up); }
|
|
}
|
|
/** Your own data as a .tar.gz: tasks, contacts, settings, Bible notes, your notes and so on. Sign-in tokens for mail, iCloud and Plex are left out. */
|
|
async function exportMine(req, res) {
|
|
const me = users.current(); if (!me) return send(res, 401, { error: 'Sign in' });
|
|
const tmp = fs.mkdtempSync(path.join(DATA_DIR, '.export-')), SECRET = new Set(['mail-accounts.json', 'mail.json', 'icloud.json', 'plex.json', 'push.json']);
|
|
try {
|
|
fs.cpSync(users.dirOf(me.id), path.join(tmp, 'me'), { recursive: true, filter: s => !SECRET.has(path.basename(s)) });
|
|
try { const notes = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'notes.json'), 'utf8')), l = Array.isArray(notes) ? notes : notes.notes || []; fs.writeFileSync(path.join(tmp, 'me', 'my-notes.json'), JSON.stringify(l.filter(n => n.owner === me.id), null, 1)); } catch { /* no notes yet */ }
|
|
const name = 'nehub-' + String(me.name || 'me').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
|
const tar = spawn('tar', ['-czf', '-', '--exclude=*.tmp', '-C', tmp, 'me'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
|
tar.stdout.pipe(res); res.on('close', () => { tar.kill(); rm(tmp); }); tar.on('error', () => res.destroy());
|
|
audit.log('personal export', { who: me.name }, req);
|
|
} catch (e) { rm(tmp); if (!res.headersSent) send(res, 500, { error: 'Could not make the export: ' + e.message }); }
|
|
}
|
|
function handle(req, res, who) {
|
|
if (new URL(req.url, 'http://x').pathname === '/api/backup/restore') return restore(req, res, who);
|
|
if (new URL(req.url, 'http://x').pathname === '/api/export') return exportMine(req, res);
|
|
if (req.method !== 'GET') { res.writeHead(405); return res.end(); }
|
|
const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
|
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=./.restore*', '--exclude=./.export-*', '--exclude=./.pre-restore-*', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); });
|
|
tar.stderr.on('data', d => console.warn('[backup]', String(d).trim()));
|
|
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
|
tar.stdout.pipe(res);
|
|
res.on('close', () => tar.kill());
|
|
audit.log('backup downloaded', { who: who || '' }, req);
|
|
}
|
|
module.exports = { handle, checkArchive };
|