diff --git a/README.md b/README.md index 24d82da..0978ea9 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн ./scripts/restore.sh # восстановление из архива ``` +Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать. + ## Безопасность - **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет. diff --git a/server.js b/server.js index cce5261..424a950 100644 --- a/server.js +++ b/server.js @@ -1357,6 +1357,17 @@ function optOriginalsPath(v, max) { return v; } +function reqPhotoRefPath(v, max) { + if (typeof v !== 'string' || v.length > max) throw new Error('Invalid photo path'); + if (isSafeUploadPath(v) || ORIGINALS_PATH_RE.test(v)) return v; + throw new Error('Invalid photo path'); +} + +function optPhotoRefPath(v, max) { + if (v === null || v === undefined) return null; + return reqPhotoRefPath(v, max); +} + const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']); function optAiText(v, max) { @@ -1597,8 +1608,8 @@ function normalizeRestoreData(data) { entry_id: reqInt(x.entry_id), action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai', params: (x.params === null || x.params === undefined) ? null : (typeof x.params === 'object' ? JSON.stringify(x.params) : String(x.params)), - before_path: optUploadPath(x.before_path, 255), - after_path: optUploadPath(x.after_path, 255), + before_path: optPhotoRefPath(x.before_path, 255), + after_path: optPhotoRefPath(x.after_path, 255), status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending', applied: !!x.applied, attempts: optInt(x.attempts, 0, 2147483647) ?? 0, @@ -1787,6 +1798,11 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req legacyPhotos = data.photos || []; } else { await tar.x({ file: req.file.path, cwd: staging }); + if (!fs.existsSync(path.join(staging, 'data.json')) && fs.existsSync(path.join(staging, 'db.sql.gz'))) { + fs.rmSync(staging, { recursive: true, force: true }); + cleanupUpload(req); + return res.status(400).json({ error: 'Это архив скрипта scripts/backup.sh (db.sql.gz + _uploads) — восстанавливайте его через scripts/restore.sh. Для веб-восстановления скачайте архив в Настройках админки.' }); + } data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8')); } } catch {