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:
@@ -182,7 +182,9 @@ branches
|
|||||||
| GET | `/api/share/:token` | Public | Share page data |
|
| GET | `/api/share/:token` | Public | Share page data |
|
||||||
| GET | `/api/links` | Admin | List share links |
|
| GET | `/api/links` | Admin | List share links |
|
||||||
| POST/PUT/DELETE | `/api/links` | Admin | CRUD 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 |
|
| POST | `/api/restore` | Admin | Upload & restore backup |
|
||||||
| GET | `/api/dashboard` | Admin | Dashboard data |
|
| GET | `/api/dashboard` | Admin | Dashboard data |
|
||||||
| GET | `/api/stats` | Admin | Stats cards |
|
| GET | `/api/stats` | Admin | Stats cards |
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
||||||
- Восстановить из файла бэкапа
|
- Восстановить из файла бэкапа
|
||||||
|
|
||||||
|
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
|
||||||
|
|
||||||
Также доступны скрипты на хосте:
|
Также доступны скрипты на хосте:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+30
-12
@@ -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() {
|
async function downloadBackup() {
|
||||||
const btn = document.getElementById('backupBtn');
|
const btn = document.getElementById('backupBtn');
|
||||||
|
const st = document.getElementById('backupStatus');
|
||||||
btn.dataset.loadingText = 'Формирование...';
|
btn.dataset.loadingText = 'Формирование...';
|
||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
|
if (st) { st.classList.remove('err'); st.textContent = 'Формирование архива на сервере…'; }
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
const res = await fetch(`${API}/api/backup`, { method: 'POST', headers: hdr() });
|
||||||
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
|
const data = await res.json().catch(() => ({}));
|
||||||
const blob = await res.blob();
|
if (!res.ok) {
|
||||||
const cd = res.headers.get('Content-Disposition') || '';
|
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап'; }
|
||||||
const m = cd.match(/filename="([^"]+)"/);
|
showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
|
||||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
return;
|
||||||
const url = URL.createObjectURL(blob);
|
}
|
||||||
|
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');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = data.url;
|
||||||
a.download = name;
|
a.download = data.filename || 'whatido-backup.tar.gz';
|
||||||
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
a.remove();
|
||||||
showToast('Бэкап скачан');
|
const size = formatBackupSize(data.size);
|
||||||
} catch {
|
if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
|
||||||
|
showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
|
||||||
|
} catch (err) {
|
||||||
|
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
|
||||||
showError('Ошибка сети при формировании бэкапа');
|
showError('Ошибка сети при формировании бэкапа');
|
||||||
} finally {
|
} finally {
|
||||||
setBtnLoading(btn, false);
|
setBtnLoading(btn, false);
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
<h3>Резервное копирование</h3>
|
<h3>Резервное копирование</h3>
|
||||||
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
|
||||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
||||||
@@ -181,6 +181,7 @@
|
|||||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
|
<div class="restore-status" id="backupStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="sec-form">
|
<div class="settings-card" id="sec-form">
|
||||||
|
|||||||
@@ -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 };
|
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-'));
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||||
try {
|
try {
|
||||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj] = await Promise.all([
|
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj] = await Promise.all([
|
||||||
@@ -1649,21 +1677,73 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
||||||
const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`);
|
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
await tar.c({ gzip: true, file: outPath, cwd: staging }, ['data.json', 'uploads']);
|
const outPath = path.join(BACKUP_DIR, `whatido-backup-${stamp}-${crypto.randomBytes(4).toString('hex')}.tar.gz`);
|
||||||
const buf = fs.readFileSync(outPath);
|
await tar.c({ gzip: { level: 1 }, file: outPath, cwd: staging }, ['data.json', 'uploads']);
|
||||||
fs.unlinkSync(outPath);
|
const { size } = fs.statSync(outPath);
|
||||||
res.setHeader('Content-Type', 'application/gzip');
|
return { file: outPath, name: `whatido-backup-${stamp}.tar.gz`, size };
|
||||||
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 });
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
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) {
|
function cleanupUpload(req) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user