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/
100 lines
2.6 KiB
JavaScript
100 lines
2.6 KiB
JavaScript
"use strict";
|
|
|
|
const { TooManyRequestsError } = require("../../errors");
|
|
|
|
function getClientIp(req) {
|
|
return (
|
|
req.ip ||
|
|
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
|
req.socket?.remoteAddress ||
|
|
"127.0.0.1"
|
|
);
|
|
}
|
|
|
|
class RateLimiter {
|
|
/**
|
|
* @param {Object} options
|
|
* @param {number} [options.windowMs=900000] - 15 минут
|
|
* @param {number} [options.max=5] - Максимум попыток
|
|
* @param {import('../../logger').Logger} [options.logger]
|
|
*/
|
|
constructor({ windowMs = 15 * 60 * 1000, max = 5, logger = null }) {
|
|
this.windowMs = windowMs;
|
|
this.max = max;
|
|
this.logger = logger;
|
|
/** @type {Map<string, number[]>} IP -> array of failure timestamps */
|
|
this.failures = new Map();
|
|
|
|
this._cleanupInterval = setInterval(() => this.cleanup(), this.windowMs);
|
|
if (this._cleanupInterval.unref) {
|
|
this._cleanupInterval.unref();
|
|
}
|
|
}
|
|
|
|
middleware() {
|
|
return (req, res, next) => {
|
|
const ip = getClientIp(req);
|
|
const now = Date.now();
|
|
const timestamps = this.failures.get(ip) || [];
|
|
|
|
// Filter timestamps within current window
|
|
const recent = timestamps.filter((t) => now - t < this.windowMs);
|
|
this.failures.set(ip, recent);
|
|
|
|
if (recent.length >= this.max) {
|
|
if (this.logger) {
|
|
this.logger.warn("Превышен лимит попыток входа (rate limit)", {
|
|
ip,
|
|
attempts: recent.length,
|
|
});
|
|
}
|
|
return next(
|
|
new TooManyRequestsError(
|
|
"Слишком много неудачных попыток входа. Подождите 15 минут перед следующей попыткой."
|
|
)
|
|
);
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
recordFailure(req) {
|
|
const ip = getClientIp(req);
|
|
const now = Date.now();
|
|
const timestamps = this.failures.get(ip) || [];
|
|
const recent = timestamps.filter((t) => now - t < this.windowMs);
|
|
recent.push(now);
|
|
this.failures.set(ip, recent);
|
|
}
|
|
|
|
recordSuccess(req) {
|
|
const ip = getClientIp(req);
|
|
this.failures.delete(ip);
|
|
}
|
|
|
|
cleanup() {
|
|
const now = Date.now();
|
|
for (const [ip, timestamps] of this.failures) {
|
|
const recent = timestamps.filter((t) => now - t < this.windowMs);
|
|
if (recent.length === 0) {
|
|
this.failures.delete(ip);
|
|
} else {
|
|
this.failures.set(ip, recent);
|
|
}
|
|
}
|
|
}
|
|
|
|
close() {
|
|
if (this._cleanupInterval) {
|
|
clearInterval(this._cleanupInterval);
|
|
this._cleanupInterval = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
RateLimiter,
|
|
getClientIp,
|
|
};
|