fix(backup): accept /uploads/.originals paths in photo_jobs on restore
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: форматы скриптового и веб-архива не взаимозаменяемы
This commit is contained in:
@@ -235,6 +235,8 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
./scripts/restore.sh # восстановление из архива
|
./scripts/restore.sh # восстановление из архива
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
|
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
|
||||||
|
|||||||
@@ -1357,6 +1357,17 @@ function optOriginalsPath(v, max) {
|
|||||||
return v;
|
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']);
|
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
|
||||||
|
|
||||||
function optAiText(v, max) {
|
function optAiText(v, max) {
|
||||||
@@ -1597,8 +1608,8 @@ function normalizeRestoreData(data) {
|
|||||||
entry_id: reqInt(x.entry_id),
|
entry_id: reqInt(x.entry_id),
|
||||||
action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai',
|
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)),
|
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),
|
before_path: optPhotoRefPath(x.before_path, 255),
|
||||||
after_path: optUploadPath(x.after_path, 255),
|
after_path: optPhotoRefPath(x.after_path, 255),
|
||||||
status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending',
|
status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending',
|
||||||
applied: !!x.applied,
|
applied: !!x.applied,
|
||||||
attempts: optInt(x.attempts, 0, 2147483647) ?? 0,
|
attempts: optInt(x.attempts, 0, 2147483647) ?? 0,
|
||||||
@@ -1787,6 +1798,11 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
legacyPhotos = data.photos || [];
|
legacyPhotos = data.photos || [];
|
||||||
} else {
|
} else {
|
||||||
await tar.x({ file: req.file.path, cwd: staging });
|
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'));
|
data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user