diff --git a/AGENTS.md b/AGENTS.md index b4dcb5e..5fbaaed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ### 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) - Формат архива: `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`, и здесь** diff --git a/public/js/settings.js b/public/js/settings.js index d02e6e7..93413cb 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -863,11 +863,19 @@ async function downloadBackup() { 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 size = formatBackupSize(data.size); + if (st) { + st.classList.remove('err'); + st.innerHTML = ''; + 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'); a.href = data.url; @@ -875,8 +883,6 @@ async function downloadBackup() { document.body.appendChild(a); a.click(); 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; } diff --git a/server.js b/server.js index 965c0a4..e3e2c64 100644 --- a/server.js +++ b/server.js @@ -2135,23 +2135,33 @@ const uploadBackup = multer({ const BACKUP_TTL_MS = 30 * 60 * 1000; +const BACKUP_TICKETS_MAX = 3; const BACKUP_DIR = path.join(os.tmpdir(), 'wido-backups'); 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() { const now = Date.now(); for (const [token, t] of backupTickets) { - if (t.expiresAt <= now) { - backupTickets.delete(token); - fs.rmSync(t.file, { force: true }); - } + if (t.expiresAt <= now) dropBackupTicket(token); } + 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() { try { 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)) { const fp = path.join(BACKUP_DIR, f); const st = fs.statSync(fp); @@ -2228,11 +2238,11 @@ function sendBackupArchive(res, archive, onDone) { 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 }); + pruneBackupTickets(); await logAudit(req, 'backup.download', { size: archive.size, counts: archive.counts }); await pushNotification({ 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 ticket = backupTickets.get(token); if (!ticket || ticket.expiresAt <= Date.now()) { - if (ticket) { - backupTickets.delete(token); - fs.rmSync(ticket.file, { force: true }); - } + if (ticket) dropBackupTicket(token); return res.status(404).json({ error: 'Ссылка на бэкап устарела. Сформируйте архив заново.' }); } if (!fs.existsSync(ticket.file)) { - backupTickets.delete(token); + dropBackupTicket(token); return res.status(410).json({ error: 'Файл бэкапа больше недоступен. Сформируйте архив заново.' }); } - backupTickets.delete(token); - sendBackupArchive(res, ticket, () => { - try { fs.rmSync(ticket.file, { force: true }); } catch (e) { - console.error('backup cleanup failed:', e.message); - } - }); + sendBackupArchive(res, ticket); }); app.get('/api/backup', requireAdmin, async (req, res) => {