v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s
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:
151
src/http/routes/history.routes.js
Normal file
151
src/http/routes/history.routes.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user