From 69d46a0e5f10df1161a3e6f42bfc196557a6289e Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 23 Sep 2026 19:03:19 +0300 Subject: [PATCH] fix(backup): stream backup download via resumable token link instead of buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Формирование и скачивание бэкапа разделены: 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: нативное скачивание браузером с прогрессом и докачкой, понятные ошибки вместо «Ошибка сети при формировании бэкапа» --- PRD.md | 4 +- README.md | 2 + public/js/settings.js | 42 ++++++++++++----- public/settings.html | 3 +- server.js | 104 +++++++++++++++++++++++++++++++++++++----- 5 files changed, 129 insertions(+), 26 deletions(-) diff --git a/PRD.md b/PRD.md index c4ada66..067a690 100644 --- a/PRD.md +++ b/PRD.md @@ -182,7 +182,9 @@ branches | GET | `/api/share/:token` | Public | Share page data | | GET | `/api/links` | Admin | List share links | | POST/PUT/DELETE | `/api/links` | Admin | CRUD share links | -| GET | `/api/backup` | Admin | Download backup | +| GET | `/api/backup` | Admin | Build & download backup (compat) | +| POST | `/api/backup` | Admin | Build backup, returns download URL | +| GET | `/api/backup/:token` | Token | Download built backup (resumable) | | POST | `/api/restore` | Admin | Upload & restore backup | | GET | `/api/dashboard` | Admin | Dashboard data | | GET | `/api/stats` | Admin | Stats cards | diff --git a/README.md b/README.md index 4310ea0..24d82da 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,8 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн - Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/` - Восстановить из файла бэкапа +Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу. + Также доступны скрипты на хосте: ```bash diff --git a/public/js/settings.js b/public/js/settings.js index f34a7a7..381bda7 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -540,25 +540,43 @@ async function saveSettings() { } } +function formatBackupSize(b) { + if (!b || b < 0) return ''; + if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' ГБ'; + return (b / 1048576).toFixed(1) + ' МБ'; +} + async function downloadBackup() { const btn = document.getElementById('backupBtn'); + const st = document.getElementById('backupStatus'); btn.dataset.loadingText = 'Формирование...'; setBtnLoading(btn, true); + if (st) { st.classList.remove('err'); st.textContent = 'Формирование архива на сервере…'; } try { - const res = await fetch(`${API}/api/backup`, { headers: hdr() }); - if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; } - const blob = await res.blob(); - const cd = res.headers.get('Content-Disposition') || ''; - const m = cd.match(/filename="([^"]+)"/); - const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`; - const url = URL.createObjectURL(blob); + const res = await fetch(`${API}/api/backup`, { method: 'POST', headers: hdr() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап'; } + showError('Ошибка формирования бэкапа: ' + (data.error || res.status)); + return; + } + const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null); + if (head && !head.ok) { + if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; } + showError('Ошибка скачивания бэкапа'); + return; + } const a = document.createElement('a'); - a.href = url; - a.download = name; + a.href = data.url; + a.download = data.filename || 'whatido-backup.tar.gz'; + document.body.appendChild(a); a.click(); - URL.revokeObjectURL(url); - showToast('Бэкап скачан'); - } catch { + a.remove(); + const size = formatBackupSize(data.size); + if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`; + showToast('Бэкап сформирован' + (size ? ': ' + size : '')); + } catch (err) { + if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; } showError('Ошибка сети при формировании бэкапа'); } finally { setBtnLoading(btn, false); diff --git a/public/settings.html b/public/settings.html index cf4b76b..c072703 100644 --- a/public/settings.html +++ b/public/settings.html @@ -173,7 +173,7 @@

Резервное копирование

-

Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.

+

Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.

Загрузка статистики…
@@ -181,6 +181,7 @@
+
diff --git a/server.js b/server.js index 697f9d1..cce5261 100644 --- a/server.js +++ b/server.js @@ -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 });