fix(backup): stream backup download via resumable token link instead of buffering
Формирование и скачивание бэкапа разделены: POST /api/backup собирает архив на диске и возвращает временную ссылку, GET /api/backup/:token отдаёт его через res.download (Content-Length, Accept-Ranges, 206 при докачке). - больше нет fs.readFileSync всего архива и res.send буфера (~550 МБ RAM -> ~60 МБ) - GET /api/backup сохранён для совместимости, тоже потоковый - gzip level 1 (архив из JPEG почти не сжимается), чистка /tmp/wido-backups по TTL 30 мин - settings.html/js: нативное скачивание браузером с прогрессом и докачкой, понятные ошибки вместо «Ошибка сети при формировании бэкапа»
This commit is contained in:
@@ -1609,7 +1609,35 @@ function normalizeRestoreData(data) {
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs };
|
||||
}
|
||||
|
||||
app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||
const BACKUP_TTL_MS = 30 * 60 * 1000;
|
||||
const BACKUP_DIR = path.join(os.tmpdir(), 'wido-backups');
|
||||
const backupTickets = new Map();
|
||||
|
||||
function pruneBackupTickets() {
|
||||
const now = Date.now();
|
||||
for (const [token, t] of backupTickets) {
|
||||
if (t.expiresAt <= now) {
|
||||
backupTickets.delete(token);
|
||||
fs.rmSync(t.file, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sweepBackupStorage() {
|
||||
try {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return;
|
||||
const cutoff = Date.now() - BACKUP_TTL_MS;
|
||||
for (const f of fs.readdirSync(BACKUP_DIR)) {
|
||||
const fp = path.join(BACKUP_DIR, f);
|
||||
const st = fs.statSync(fp);
|
||||
if (st.isFile() && st.mtimeMs < cutoff) fs.rmSync(fp, { force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('backup sweep failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBackupArchive() {
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||
try {
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj] = await Promise.all([
|
||||
@@ -1649,22 +1677,74 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
||||
const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`);
|
||||
await tar.c({ gzip: true, file: outPath, cwd: staging }, ['data.json', 'uploads']);
|
||||
const buf = fs.readFileSync(outPath);
|
||||
fs.unlinkSync(outPath);
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`);
|
||||
await logAudit(req, 'backup.download', {});
|
||||
res.send(buf);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
const outPath = path.join(BACKUP_DIR, `whatido-backup-${stamp}-${crypto.randomBytes(4).toString('hex')}.tar.gz`);
|
||||
await tar.c({ gzip: { level: 1 }, file: outPath, cwd: staging }, ['data.json', 'uploads']);
|
||||
const { size } = fs.statSync(outPath);
|
||||
return { file: outPath, name: `whatido-backup-${stamp}.tar.gz`, size };
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function sendBackupArchive(res, archive) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.download(archive.file, archive.name, (err) => {
|
||||
if (err && !res.headersSent) res.status(500).json({ error: 'Не удалось отправить бэкап' });
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/api/backup', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
pruneBackupTickets();
|
||||
const archive = await buildBackupArchive();
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const expiresAt = Date.now() + BACKUP_TTL_MS;
|
||||
backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt });
|
||||
await logAudit(req, 'backup.download', { size: archive.size });
|
||||
res.json({
|
||||
url: `/api/backup/${token}`,
|
||||
filename: archive.name,
|
||||
size: archive.size,
|
||||
expires_at: new Date(expiresAt).toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/backup/:token', apiLimiter, (req, res) => {
|
||||
const token = typeof req.params.token === 'string' ? req.params.token : '';
|
||||
const ticket = backupTickets.get(token);
|
||||
if (!ticket || ticket.expiresAt <= Date.now()) {
|
||||
if (ticket) {
|
||||
backupTickets.delete(token);
|
||||
fs.rmSync(ticket.file, { force: true });
|
||||
}
|
||||
return res.status(404).json({ error: 'Ссылка на бэкап устарела. Сформируйте архив заново.' });
|
||||
}
|
||||
if (!fs.existsSync(ticket.file)) {
|
||||
backupTickets.delete(token);
|
||||
return res.status(410).json({ error: 'Файл бэкапа больше недоступен. Сформируйте архив заново.' });
|
||||
}
|
||||
sendBackupArchive(res, ticket);
|
||||
});
|
||||
|
||||
app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const archive = await buildBackupArchive();
|
||||
await logAudit(req, 'backup.download', { size: archive.size });
|
||||
sendBackupArchive(res, archive);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
sweepBackupStorage();
|
||||
setInterval(() => { pruneBackupTickets(); sweepBackupStorage(); }, 60 * 1000).unref();
|
||||
|
||||
function cleanupUpload(req) {
|
||||
try {
|
||||
if (req?.file?.destination) fs.rmSync(req.file.destination, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user