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

114
src/core/auth/password.js Normal file
View File

@@ -0,0 +1,114 @@
"use strict";
const crypto = require("crypto");
const { promisify } = require("util");
const scryptAsync = promisify(crypto.scrypt);
const KEY_LEN = 64;
const SALT_LEN = 16;
const SCRYPT_OPTIONS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
};
/**
* Хеширует пароль с помощью scrypt и случайной соли.
* @param {string} password
* @returns {Promise<string>} Формат: "saltHex:derivedKeyHex"
*/
async function hashPassword(password) {
if (typeof password !== "string") {
throw new TypeError("Пароль должен быть строкой");
}
const salt = crypto.randomBytes(SALT_LEN);
const derivedKey = await scryptAsync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
}
/**
* Синхронное хеширование (для инициализации при старте).
* @param {string} password
* @returns {string}
*/
function hashPasswordSync(password) {
if (typeof password !== "string") {
throw new TypeError("Пароль должен быть строкой");
}
const salt = crypto.randomBytes(SALT_LEN);
const derivedKey = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
}
/**
* Проверяет пароль против сохранённого scrypt-хеша с защитой от тайминг-атак.
* @param {string} password
* @param {string} storedHash
* @returns {Promise<boolean>}
*/
async function verifyPassword(password, storedHash) {
if (typeof password !== "string" || typeof storedHash !== "string") {
return false;
}
const parts = storedHash.split(":");
if (parts.length !== 2) {
return false;
}
const [saltHex, keyHex] = parts;
try {
const salt = Buffer.from(saltHex, "hex");
const originalKey = Buffer.from(keyHex, "hex");
if (salt.length !== SALT_LEN || originalKey.length !== KEY_LEN) {
return false;
}
const derivedKey = await scryptAsync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return crypto.timingSafeEqual(originalKey, derivedKey);
} catch {
return false;
}
}
/**
* Синхронная проверка пароля.
* @param {string} password
* @param {string} storedHash
* @returns {boolean}
*/
function verifyPasswordSync(password, storedHash) {
if (typeof password !== "string" || typeof storedHash !== "string") {
return false;
}
const parts = storedHash.split(":");
if (parts.length !== 2) {
return false;
}
const [saltHex, keyHex] = parts;
try {
const salt = Buffer.from(saltHex, "hex");
const originalKey = Buffer.from(keyHex, "hex");
if (salt.length !== SALT_LEN || originalKey.length !== KEY_LEN) {
return false;
}
const derivedKey = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return crypto.timingSafeEqual(originalKey, derivedKey);
} catch {
return false;
}
}
module.exports = {
hashPassword,
hashPasswordSync,
verifyPassword,
verifyPasswordSync,
};