From a95af7daa76d37fbb1c4f1e6dcf19175df0a66e0 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 23 Sep 2026 22:23:38 +0300 Subject: [PATCH] fix(backup): accept /uploads/.originals paths in photo_jobs on restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit photo_jobs.before_path хранит путь к оригиналу фото (/uploads/.originals/<файл>), но normalizeRestoreData проверял это поле через optUploadPath/isSafeUploadPath, который запрещает "/" — при наличии завершённых улучшений фото весь импорт падал с 400 «Неверный формат бэкапа: Invalid upload path». - добавлены reqPhotoRefPath/optPhotoRefPath: допустимы /uploads/<файл> и /uploads/.originals/<файл> (та же ORIGINALS_PATH_RE, что и для entries.photo_original_path); применяются к photo_jobs.before_path/after_path - POST /api/restore: понятная ошибка, если загружен архив скрипта scripts/backup.sh (db.sql.gz + _uploads) вместо веб-архива - README: форматы скриптового и веб-архива не взаимозаменяемы --- README.md | 2 ++ server.js | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) 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 {