fix(photo-ai): филиальная изоляция в опросе и удалении превью

Два хендлера фото-воркера работали с requireAuth, но без entryAccessible,
в отличие от соседних /apply, /reject, /rollback. Тьютор одного филиала мог
доставать данные job'а записи другого филиала.

Закрыто:
- GET /api/entries/:id/photo/enhance-ai/:jobId отдавал after_path чужой
  записи; добавлена проверка entryAccessible -> 403
- DELETE /api/entries/:id/photo/enhance-ai/preview вызывал safeUnlink(p)
  по пути из тела запроса, проверенному только синтаксически через
  isSafeUploadPath; теперь путь сначала сверяется с photo_jobs по
  (entry_id, after_path, status='done', applied=false) и файл удаляется
  только при реальном совпадении, иначе 404

Дополнительно:
- оба хендлера обёрнуты в try/catch: без глобального error handler
  необработанная ошибка в async-роуте оставляла бы запрос висеть
- в DELETE добавлен logAudit('entry.photo.reject'), которого не было;
  метка уже есть в public/js/audit.js

Проверено: изолированный тест на два филиала (15 проверок) — 403 для
чужого филиала на обоих эндпоинтах, happy-path владельца сохранён
(чтение job и удаление превью, job -> rejected), соседний /jobs без
регрессий; api.smoketest.js 76 PASS без регрессий.

Замечание для деплоя: server.js запечён в образ, compose монтирует
только uploads/, поэтому restart не подхватит правку — нужен
./scripts/deploy.sh или docker compose up -d --build app.
This commit is contained in:
dev
2026-10-04 23:39:06 +03:00
parent 678cb97bb9
commit cd40260b68
+33 -4
View File
@@ -6159,6 +6159,12 @@ app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
}); });
app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => { app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => {
try {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const jobId = parseInt(req.params.jobId, 10); const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' }); if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -6171,6 +6177,10 @@ app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res
if (job.status === 'error') out.error = job.error; if (job.status === 'error') out.error = job.error;
if (job.status === 'done') out.photo_path = job.after_path; if (job.status === 'done') out.photo_path = job.after_path;
res.json(out); res.json(out);
} catch (e) {
console.error('GET /api/entries/:id/photo/enhance-ai/:jobId:', e);
res.status(500).json({ error: 'Ошибка получения статуса задания' });
}
}); });
app.post('/api/entries/:id/photo/jobs/:jobId/apply', requireAuth, async (req, res) => { app.post('/api/entries/:id/photo/jobs/:jobId/apply', requireAuth, async (req, res) => {
@@ -6347,15 +6357,34 @@ app.post('/api/entries/:id/photo/jobs/:jobId/rollback', requireAuth, async (req,
}); });
app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req, res) => { app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
try {
const { path: p } = req.body || {}; const { path: p } = req.body || {};
if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' }); if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' });
safeUnlink(p); const { rows } = await pool.query(
await pool.query( `SELECT id, after_path FROM photo_jobs
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now() WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false
WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false`, ORDER BY id DESC LIMIT 1`,
[req.params.id, p] [req.params.id, p]
); );
const job = rows[0];
if (!job) return res.status(404).json({ error: 'Результат не найден' });
if (isSafeUploadPath(job.after_path)) safeUnlink(job.after_path);
await pool.query(
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
WHERE id = $1 AND applied = false`,
[job.id]
);
await logAudit(req, 'entry.photo.reject', { entry_id: req.params.id, job_id: job.id });
res.json({ ok: true }); res.json({ ok: true });
} catch (e) {
console.error('DELETE /api/entries/:id/photo/enhance-ai/preview:', e);
res.status(500).json({ error: 'Ошибка удаления результата' });
}
}); });
app.post('/api/entries/:id/photo/restore-original', requireAuth, async (req, res) => { app.post('/api/entries/:id/photo/restore-original', requireAuth, async (req, res) => {