// 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-; 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 };