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
+50 -21
View File
@@ -6159,18 +6159,28 @@ 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) => {
const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
const { rows } = await pool.query(
'SELECT id, status, error, after_path, applied FROM photo_jobs WHERE id = $1 AND entry_id = $2',
[jobId, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' });
const job = rows[0];
const out = { status: job.status, applied: job.applied, job_id: job.id };
if (job.status === 'error') out.error = job.error;
if (job.status === 'done') out.photo_path = job.after_path;
res.json(out);
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);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
const { rows } = await pool.query(
'SELECT id, status, error, after_path, applied FROM photo_jobs WHERE id = $1 AND entry_id = $2',
[jobId, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' });
const job = rows[0];
const out = { status: job.status, applied: job.applied, job_id: job.id };
if (job.status === 'error') out.error = job.error;
if (job.status === 'done') out.photo_path = job.after_path;
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) => {
@@ -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) => {
const { path: p } = req.body || {};
if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' });
safeUnlink(p);
await pool.query(
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false`,
[req.params.id, p]
);
res.json({ ok: true });
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 || {};
if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' });
const { rows } = await pool.query(
`SELECT id, after_path FROM photo_jobs
WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false
ORDER BY id DESC LIMIT 1`,
[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 });
} 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) => {