fix(backup): ссылка на архив переживает F5 и повторное скачивание

HEAD-проба в downloadBackup() сжигала одноразовый тикет: Express 4
прогоняет HEAD через GET-хендлер /api/backup/:token, который удалял
тикет и файл до отдачи архива, поэтому настоящий GET всегда получал
404 «Ссылка на бэкап устарела».

- server.js: GET /api/backup/:token больше не удаляет тикет и файл —
  ссылка живёт BACKUP_TTL_MS (30 мин), Range-докачка работает
- вынес dropBackupTicket(), fs.rmSync обёрнут в try/catch
- BACKUP_TICKETS_MAX = 3: лишние тикеты вычищаются по возрасту
- sweepBackupStorage() режет с запасом 5 минут сверх TTL
- settings.js: убрана HEAD-проба, в #backupStatus рендерится реальная
  кликабельная ссылка вместо невидимого синтетического <a>
- AGENTS.md: раздел 8 — новое поведение + предупреждение про HEAD
This commit is contained in:
dev
2026-10-04 09:07:16 +03:00
parent 76d36e5c35
commit 19b2365b4c
3 changed files with 34 additions and 25 deletions
+2 -1
View File
@@ -155,7 +155,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container - Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
### 8. Backup / Restore ### 8. Backup / Restore
- **Admin UI**: `POST /api/backup` → тикет + `GET /api/backup/:token` (разовая ссылка, 30 мин), `POST /api/restore` (upload `.tar.gz`) - **Admin UI**: `POST /api/backup` → тикет + `GET /api/backup/:token` (ссылка живёт `BACKUP_TTL_MS`, 30 мин, **скачивание можно повторять** — в том числе после обрыва связи и F5; не «сжигать» тикет `HEAD`-пробой, Express 4 отдаёт HEAD через GET-хендлер), `POST /api/restore` (upload `.tar.gz`)
- **Хранение тикетов**: in-memory `Map` (`server.js`), поэтому после рестарта приложения ссылка даёт 404 — это ожидаемо. Одновременно живых тикетов не больше `BACKUP_TICKETS_MAX`, лишние и истёкшие вычищаются `pruneBackupTickets()`; файлы доживают `sweepBackupStorage()` с запасом в 5 минут сверх TTL
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level) - **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
- Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore` - Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore`
- `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь** - `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь**
+13 -7
View File
@@ -863,11 +863,19 @@ async function downloadBackup() {
showError('Ошибка формирования бэкапа: ' + (data.error || res.status)); showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
return; return;
} }
const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null); const size = formatBackupSize(data.size);
if (head && !head.ok) { if (st) {
if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; } st.classList.remove('err');
showError('Ошибка скачивания бэкапа'); st.innerHTML = '';
return; const link = document.createElement('a');
link.href = data.url;
link.download = data.filename || 'whatido-backup.tar.gz';
link.textContent = data.filename || 'whatido-backup.tar.gz';
link.style.color = 'var(--accent)';
st.appendChild(link);
const note = document.createElement('span');
note.textContent = `${size ? ` (${size})` : ''} — ссылка активна 30 минут, скачивание можно повторить`;
st.appendChild(note);
} }
const a = document.createElement('a'); const a = document.createElement('a');
a.href = data.url; a.href = data.url;
@@ -875,8 +883,6 @@ async function downloadBackup() {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
const size = formatBackupSize(data.size);
if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
showToast('Бэкап сформирован' + (size ? ': ' + size : '')); showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
} catch (err) { } catch (err) {
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; } if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
+19 -17
View File
@@ -2135,23 +2135,33 @@ const uploadBackup = multer({
const BACKUP_TTL_MS = 30 * 60 * 1000; const BACKUP_TTL_MS = 30 * 60 * 1000;
const BACKUP_TICKETS_MAX = 3;
const BACKUP_DIR = path.join(os.tmpdir(), 'wido-backups'); const BACKUP_DIR = path.join(os.tmpdir(), 'wido-backups');
const backupTickets = new Map(); const backupTickets = new Map();
function dropBackupTicket(token) {
const t = backupTickets.get(token);
if (!t) return;
backupTickets.delete(token);
try { fs.rmSync(t.file, { force: true }); } catch (e) {
console.error('backup cleanup failed:', e.message);
}
}
function pruneBackupTickets() { function pruneBackupTickets() {
const now = Date.now(); const now = Date.now();
for (const [token, t] of backupTickets) { for (const [token, t] of backupTickets) {
if (t.expiresAt <= now) { if (t.expiresAt <= now) dropBackupTicket(token);
backupTickets.delete(token);
fs.rmSync(t.file, { force: true });
}
} }
if (backupTickets.size <= BACKUP_TICKETS_MAX) return;
const live = [...backupTickets.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
for (const [token] of live.slice(0, live.length - BACKUP_TICKETS_MAX)) dropBackupTicket(token);
} }
function sweepBackupStorage() { function sweepBackupStorage() {
try { try {
if (!fs.existsSync(BACKUP_DIR)) return; if (!fs.existsSync(BACKUP_DIR)) return;
const cutoff = Date.now() - BACKUP_TTL_MS; const cutoff = Date.now() - BACKUP_TTL_MS - 5 * 60 * 1000;
for (const f of fs.readdirSync(BACKUP_DIR)) { for (const f of fs.readdirSync(BACKUP_DIR)) {
const fp = path.join(BACKUP_DIR, f); const fp = path.join(BACKUP_DIR, f);
const st = fs.statSync(fp); const st = fs.statSync(fp);
@@ -2228,11 +2238,11 @@ function sendBackupArchive(res, archive, onDone) {
app.post('/api/backup', requireAdmin, async (req, res) => { app.post('/api/backup', requireAdmin, async (req, res) => {
try { try {
pruneBackupTickets();
const archive = await buildBackupArchive(); const archive = await buildBackupArchive();
const token = crypto.randomBytes(24).toString('hex'); const token = crypto.randomBytes(24).toString('hex');
const expiresAt = Date.now() + BACKUP_TTL_MS; const expiresAt = Date.now() + BACKUP_TTL_MS;
backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt }); backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt });
pruneBackupTickets();
await logAudit(req, 'backup.download', { size: archive.size, counts: archive.counts }); await logAudit(req, 'backup.download', { size: archive.size, counts: archive.counts });
await pushNotification({ await pushNotification({
type: 'backup.create', type: 'backup.create',
@@ -2259,22 +2269,14 @@ app.get('/api/backup/:token', apiLimiter, (req, res) => {
const token = typeof req.params.token === 'string' ? req.params.token : ''; const token = typeof req.params.token === 'string' ? req.params.token : '';
const ticket = backupTickets.get(token); const ticket = backupTickets.get(token);
if (!ticket || ticket.expiresAt <= Date.now()) { if (!ticket || ticket.expiresAt <= Date.now()) {
if (ticket) { if (ticket) dropBackupTicket(token);
backupTickets.delete(token);
fs.rmSync(ticket.file, { force: true });
}
return res.status(404).json({ error: 'Ссылка на бэкап устарела. Сформируйте архив заново.' }); return res.status(404).json({ error: 'Ссылка на бэкап устарела. Сформируйте архив заново.' });
} }
if (!fs.existsSync(ticket.file)) { if (!fs.existsSync(ticket.file)) {
backupTickets.delete(token); dropBackupTicket(token);
return res.status(410).json({ error: 'Файл бэкапа больше недоступен. Сформируйте архив заново.' }); return res.status(410).json({ error: 'Файл бэкапа больше недоступен. Сформируйте архив заново.' });
} }
backupTickets.delete(token); sendBackupArchive(res, ticket);
sendBackupArchive(res, ticket, () => {
try { fs.rmSync(ticket.file, { force: true }); } catch (e) {
console.error('backup cleanup failed:', e.message);
}
});
}); });
app.get('/api/backup', requireAdmin, async (req, res) => { app.get('/api/backup', requireAdmin, async (req, res) => {