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:
+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() {
|
||||
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);
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<h3>Резервное копирование</h3>
|
||||
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
||||
</div>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
|
||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||
<div class="card-foot">
|
||||
<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">
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
<div class="restore-status" id="backupStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-form">
|
||||
|
||||
Reference in New Issue
Block a user