v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

View File

@@ -0,0 +1,151 @@
"use strict";
const express = require("express");
const fs = require("fs");
const { sniffImageType } = require("../../core/images/sniff");
const { isHeic } = require("../../core/images/heic");
const { BadRequestError, NotFoundError } = require("../../errors");
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* @param {Object} deps
* @param {import('../../core/history/history-service').HistoryService} deps.historyService
* @param {import('multer').Multer} deps.upload
* @param {import('../../logger').Logger} deps.logger
*/
function createHistoryRoutes({ historyService, upload, logger }) {
const router = express.Router();
router.get("/history", (req, res, next) => {
try {
res.json({ ok: true, versions: historyService.getHistory() });
} catch (err) {
next(err);
}
});
router.post("/history/import", upload.single("photo"), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
throw new BadRequestError("Выберите фотографию для импорта.");
}
const isHeicFile = isHeic(req.file) || sniffImageType(req.file.buffer) === "heic";
const sniffedType = sniffImageType(req.file.buffer);
if (!isHeicFile && !sniffedType) {
throw new BadRequestError(
"Файл не является поддерживаемым изображением (JPEG, PNG, WebP или HEIC)."
);
}
const version = await historyService.importPhoto({
buffer: req.file.buffer,
mimetype: req.file.mimetype,
originalname: req.file.originalname,
});
logger.info("Импорт фотографии", {
versionId: version.id,
ext: version.ext,
width: version.width,
height: version.height,
});
res.json({ ok: true, version });
} catch (err) {
next(err);
}
});
router.delete("/history/:id", async (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор версии.");
}
const existing = historyService.getVersion(id);
if (!existing) {
throw new NotFoundError("Версия не найдена в истории.");
}
const result = await historyService.removeCascade(id);
logger.audit("Удаление версии истории", {
versionId: id,
removedCount: result.removed.length,
});
res.json({ ok: true, versions: result.remaining });
} catch (err) {
next(err);
}
});
// Alias /image.jpg for backward compatibility -> 302 redirect to actual ext
router.get("/history/:id/image.jpg", (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор версии.");
}
const version = historyService.getVersion(id);
if (!version) {
throw new NotFoundError("Версия не найдена в истории.");
}
const targetExt = version.ext || "jpg";
if (targetExt !== "jpg") {
return res.redirect(302, `/api/history/${id}/image.${targetExt}`);
}
// If ext is jpg, serve it directly
const fileInfo = historyService.getVersionFile(id);
fileInfo
.then(({ filePath, contentType }) => {
res.setHeader("content-type", contentType);
res.setHeader("cache-control", "no-store");
res.setHeader(
"content-disposition",
`inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.jpg`)}`
);
fs.createReadStream(filePath).pipe(res);
})
.catch(next);
} catch (err) {
next(err);
}
});
// Direct route for /history/:id/image.:ext (e.g. image.png, image.webp, image.jpg)
router.get("/history/:id/image.:ext", async (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор версии.");
}
const { version, filePath, contentType } = await historyService.getVersionFile(id);
res.setHeader("content-type", contentType);
res.setHeader("cache-control", "no-store");
res.setHeader(
"content-disposition",
`inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.${version.ext || "jpg"}`)}`
);
fs.createReadStream(filePath).pipe(res);
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createHistoryRoutes,
JOB_ID_PATTERN,
};