initial commit

This commit is contained in:
root
2026-05-13 14:20:41 +00:00
commit 6e178d2012
6022 changed files with 399872 additions and 0 deletions
+596
View File
@@ -0,0 +1,596 @@
import { findUserByEmailAndPassword, touchLastLogin, findUserById, createUser, userExistsByEmail } from '../repositories/user.repository.js';
import { createAuthSession, revokeSession, getAuthSession, rotateSessionTokens } from '../repositories/session.repository.js';
import {
generateRefreshToken,
generateCsrfToken,
hashToken,
signAccessToken,
verifyRefreshToken,
signRefreshToken,
} from './token.service.js';
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 30);
function buildSessionExpiresAt() {
const dt = new Date();
dt.setDate(dt.getDate() + REFRESH_TTL_DAYS);
return dt;
}
export async function loginUser({
email,
password,
userAgent,
ipAddress,
}) {
const user = await findUserByEmailAndPassword(email, password);
if (!user) {
const err = new Error('Invalid email or password');
err.statusCode = 401;
err.code = 'INVALID_CREDENTIALS';
throw err;
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
await touchLastLogin(user.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: {
id: session.id,
expiresAt: session.expires_at,
},
accessToken,
refreshToken,
csrfToken,
};
}
export async function logoutUser({ sessionId }) {
if (!sessionId) {
return;
}
await revokeSession(sessionId);
}
export async function refreshUserSession({ refreshToken, userAgent, ipAddress }) {
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch (err) {
const error = new Error('Invalid or expired refresh token');
error.code = 'INVALID_REFRESH_TOKEN';
error.status = 401;
throw error;
}
const session = await getAuthSession(payload.sid);
if (!session) {
const error = new Error('Session not found or expired');
error.code = 'SESSION_NOT_FOUND';
error.status = 401;
throw error;
}
const user = await findUserById(session.user_id);
if (!user) {
const error = new Error('User not found');
error.code = 'USER_NOT_FOUND';
error.status = 401;
throw error;
}
const newRefreshToken = generateRefreshToken();
const newCsrfToken = generateCsrfToken();
const newRefreshTokenHash = hashToken(newRefreshToken);
const newCsrfTokenHash = hashToken(newCsrfToken);
const updatedSession = await rotateSessionTokens(
session.id,
newRefreshTokenHash,
newCsrfTokenHash
);
if (!updatedSession) {
const error = new Error('Failed to rotate session tokens');
error.code = 'SESSION_ROTATION_FAILED';
error.status = 500;
throw error;
}
const accessToken = signAccessToken(user, session.id);
const refreshTokenJwt = signRefreshToken({
sub: user.id,
sid: session.id,
});
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
accessToken,
refreshToken: refreshTokenJwt,
csrfToken: newCsrfToken,
};
}
export async function registerUser({ email, password, displayName, userAgent, ipAddress }) {
const { userExistsByEmail, createUser } = await import('../repositories/user.repository.js');
if (!email || !password) {
const err = new Error('Email and password are required');
err.statusCode = 400;
err.code = 'VALIDATION_ERROR';
throw err;
}
if (password.length < 6) {
const err = new Error('Password must be at least 6 characters');
err.statusCode = 400;
err.code = 'PASSWORD_TOO_SHORT';
throw err;
}
const exists = await userExistsByEmail(email);
if (exists) {
const err = new Error('User with this email already exists');
err.statusCode = 409;
err.code = 'EMAIL_ALREADY_EXISTS';
throw err;
}
const user = await createUser({ email, password, displayName });
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function loginWithTelegram({ telegramData, userAgent, ipAddress }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData;
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
// Verify auth_date freshness (max 1 day)
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
// Antifraud: require Telegram username (filters out throwaway / bot accounts)
if (!fields.username || String(fields.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
// Verify HMAC-SHA256 signature
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
let user = await findUserByTelegramId(telegramId);
if (!user) {
// Antifraud: limit 3 new Telegram signups per IP per 24h
const recentCount = await countTelegramSignupsByIpLast24h(ipAddress);
if (recentCount >= 3) {
throw Object.assign(new Error("Too many signups from this IP"), { statusCode: 429, code: "SIGNUP_RATE_LIMIT" });
}
const displayName = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || fields.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
await recordSignupAttempt({ ipAddress, provider: 'telegram', telegramId, userAgent });
}
if (user.status !== "active") {
throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" });
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function linkTelegramAccount({ userId, telegramData }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData || {};
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
if (!fields.username || String(fields.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findTelegramOwner, linkTelegramToUser } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
const owner = await findTelegramOwner(telegramId);
if (owner && owner.id !== userId) {
throw Object.assign(new Error("Telegram already linked to another account"), { statusCode: 409, code: "TELEGRAM_ALREADY_LINKED" });
}
await linkTelegramToUser(userId, telegramId);
return { ok: true, telegramId };
}
export async function unlinkTelegramAccount(userId) {
const { unlinkTelegramFromUser } = await import("../repositories/user.repository.js");
await unlinkTelegramFromUser(userId);
return { ok: true };
}
// ─── Bot deeplink login flow ─────────────────────────────────────────────────
export async function startTelegramLoginFlow({ intent, userId, ipAddress, userAgent }) {
const crypto = await import("crypto");
const { createTgLoginToken, countTgStartsByIpLastMinutes } = await import("../repositories/tg-login.repository.js");
// Антифрод: 10 стартов с одного IP за 10 минут
const recent = await countTgStartsByIpLastMinutes(ipAddress, 10);
if (recent >= 10) {
throw Object.assign(new Error("Too many login attempts. Try again later."),
{ statusCode: 429, code: "TG_START_RATE_LIMIT" });
}
// Telegram /start payload: до 64 base64url-символов
const token = crypto.randomBytes(24).toString("base64url");
const created = await createTgLoginToken({ token, intent, userId, ipAddress, userAgent });
const botUsername = process.env.TELEGRAM_BOT_USERNAME || "One_Click_Auth_bot";
const url = `https://t.me/${botUsername}?start=${token}`;
return { token, url, expiresAt: created.expiresAt, ttlSec: created.ttlSec };
}
export async function confirmTelegramFromBot({ token, telegramUser }) {
const { findTgLoginToken, markTgTokenConfirmed, markTgTokenError } = await import("../repositories/tg-login.repository.js");
const {
findUserByTelegramId, createUserFromTelegram,
countTelegramSignupsByIpLast24h, recordSignupAttempt,
findUserById, findTelegramOwner, linkTelegramToUser,
} = await import("../repositories/user.repository.js");
const row = await findTgLoginToken(token);
if (!row) return { ok: false, code: "TOKEN_NOT_FOUND", message: "Ссылка не найдена или устарела." };
if (row.status !== "pending") return { ok: false, code: "TOKEN_NOT_PENDING", message: "Эта ссылка уже использована. Запросите новую на сайте." };
if (new Date(row.expires_at) < new Date()) {
await markTgTokenError(token, "EXPIRED");
return { ok: false, code: "EXPIRED", message: "Ссылка истекла. Запросите новую на сайте." };
}
// Антифрод: требуем username
if (!telegramUser.username || String(telegramUser.username).trim().length < 3) {
await markTgTokenError(token, "USERNAME_REQUIRED");
return { ok: false, code: "USERNAME_REQUIRED", message: "У вашего Telegram-аккаунта должно быть @username." };
}
const telegramId = Number(telegramUser.id);
if (row.intent === "link") {
if (!row.user_id) {
await markTgTokenError(token, "NO_USER");
return { ok: false, code: "NO_USER", message: "Авторизация не найдена. Войдите на сайт и попробуйте снова." };
}
const owner = await findTelegramOwner(telegramId);
if (owner && owner.id !== row.user_id) {
await markTgTokenError(token, "TELEGRAM_ALREADY_LINKED");
return { ok: false, code: "TELEGRAM_ALREADY_LINKED", message: "Этот Telegram уже привязан к другому аккаунту." };
}
await linkTelegramToUser(row.user_id, telegramId);
await markTgTokenConfirmed(token, {
telegramId,
telegramUsername: telegramUser.username,
telegramFirstName: telegramUser.first_name || null,
telegramLastName: telegramUser.last_name || null,
userId: row.user_id,
});
return { ok: true, message: "Готово! Telegram привязан. Возвращайтесь на сайт." };
}
// intent === "login"
let user = await findUserByTelegramId(telegramId);
if (!user) {
const recent = await countTelegramSignupsByIpLast24h(row.ip_address);
if (recent >= 3) {
await markTgTokenError(token, "SIGNUP_RATE_LIMIT");
return { ok: false, code: "SIGNUP_RATE_LIMIT", message: "Слишком много регистраций с этого IP. Попробуйте позже." };
}
const displayName = [telegramUser.first_name, telegramUser.last_name].filter(Boolean).join(" ") || telegramUser.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
await recordSignupAttempt({ ipAddress: row.ip_address, provider: "telegram", telegramId, userAgent: row.user_agent });
}
if (user.status !== "active") {
await markTgTokenError(token, "ACCOUNT_BLOCKED");
return { ok: false, code: "ACCOUNT_BLOCKED", message: "Аккаунт заблокирован." };
}
await markTgTokenConfirmed(token, {
telegramId,
telegramUsername: telegramUser.username,
telegramFirstName: telegramUser.first_name || null,
telegramLastName: telegramUser.last_name || null,
userId: user.id,
});
return { ok: true, message: "Готово! Можете возвращаться на сайт — вы уже вошли." };
}
export async function pollTelegramLoginFlow({ token, userAgent, ipAddress }) {
const { findTgLoginToken, consumeTgToken } = await import("../repositories/tg-login.repository.js");
const { findUserById } = await import("../repositories/user.repository.js");
const row = await findTgLoginToken(token);
if (!row) return { status: "not_found" };
if (row.status === "pending") {
if (new Date(row.expires_at) < new Date()) return { status: "expired" };
return { status: "pending" };
}
if (row.status === "error") {
return { status: "error", code: row.error_code };
}
if (row.status === "consumed") {
return { status: "consumed" };
}
if (row.status !== "confirmed") {
return { status: row.status };
}
// confirmed → consume + maybe issue session
const consumed = await consumeTgToken(token);
if (!consumed) return { status: "consumed" };
if (row.intent === "link") {
return { status: "linked" };
}
// login: создаём сессию
const user = await findUserById(row.user_id);
if (!user) return { status: "error", code: "USER_NOT_FOUND" };
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const session = await createAuthSession({
userId: user.id,
refreshTokenHash: hashToken(refreshToken),
csrfTokenHash: hashToken(csrfToken),
userAgent, ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
status: "authenticated",
user: {
id: user.id, email: user.email, displayName: user.display_name,
role: user.role, status: user.status,
},
accessToken, refreshToken, csrfToken,
sessionExpiresAt: session.expires_at,
};
}
// ─── Telegram Mini App (Web App) login by initData ───────────────────────────
// Спека: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
// Алгоритм: secret = HMAC_SHA256("WebAppData", BOT_TOKEN);
// expected = HMAC_SHA256(secret, data_check_string).hex();
// data_check_string = отсортированные по ключу пары "k=v", разделённые "\n", без поля "hash".
export async function loginWithTelegramInitData({ initData, userAgent, ipAddress }) {
const crypto = await import("crypto");
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) {
throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
}
if (!initData || typeof initData !== "string") {
throw Object.assign(new Error("initData is required"), { statusCode: 400, code: "INIT_DATA_REQUIRED" });
}
// Парсим как URLSearchParams
const params = new URLSearchParams(initData);
const hash = params.get("hash");
if (!hash) {
throw Object.assign(new Error("hash missing"), { statusCode: 401, code: "INVALID_INIT_DATA" });
}
params.delete("hash");
const pairs = [];
for (const [k, v] of params.entries()) pairs.push([k, v]);
pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
const dataCheckString = pairs.map(([k, v]) => `${k}=${v}`).join("\n");
const secretKey = crypto.createHmac("sha256", "WebAppData").update(BOT_TOKEN).digest();
const expected = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
// Константно-временное сравнение
const a = Buffer.from(expected, "hex");
const b = Buffer.from(hash, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_INIT_DATA" });
}
// Свежесть auth_date (24 часа, как в widget-flow)
const authDate = Number(params.get("auth_date"));
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
// Парсим user
let tgUser;
try {
tgUser = JSON.parse(params.get("user") || "null");
} catch {
tgUser = null;
}
if (!tgUser || !tgUser.id) {
throw Object.assign(new Error("Telegram user missing"), { statusCode: 400, code: "TELEGRAM_USER_MISSING" });
}
// Антифрод: требуем username
if (!tgUser.username || String(tgUser.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt } =
await import("../repositories/user.repository.js");
const telegramId = Number(tgUser.id);
let user = await findUserByTelegramId(telegramId);
if (!user) {
// Антифрод: 3 регистрации с одного IP за 24 часа (общий счётчик с widget/bot-flow)
const recentCount = await countTelegramSignupsByIpLast24h(ipAddress);
if (recentCount >= 3) {
throw Object.assign(new Error("Too many signups from this IP"), { statusCode: 429, code: "SIGNUP_RATE_LIMIT" });
}
const displayName =
[tgUser.first_name, tgUser.last_name].filter(Boolean).join(" ") || tgUser.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
await recordSignupAttempt({ ipAddress, provider: "telegram_webapp", telegramId, userAgent });
}
if (user.status !== "active") {
throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" });
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
@@ -0,0 +1,274 @@
import { findUserByEmailAndPassword, touchLastLogin, findUserById, createUser, userExistsByEmail } from '../repositories/user.repository.js';
import { createAuthSession, revokeSession, getAuthSession, rotateSessionTokens } from '../repositories/session.repository.js';
import {
generateRefreshToken,
generateCsrfToken,
hashToken,
signAccessToken,
verifyRefreshToken,
signRefreshToken,
} from './token.service.js';
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 30);
function buildSessionExpiresAt() {
const dt = new Date();
dt.setDate(dt.getDate() + REFRESH_TTL_DAYS);
return dt;
}
export async function loginUser({
email,
password,
userAgent,
ipAddress,
}) {
const user = await findUserByEmailAndPassword(email, password);
if (!user) {
const err = new Error('Invalid email or password');
err.statusCode = 401;
err.code = 'INVALID_CREDENTIALS';
throw err;
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
await touchLastLogin(user.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: {
id: session.id,
expiresAt: session.expires_at,
},
accessToken,
refreshToken,
csrfToken,
};
}
export async function logoutUser({ sessionId }) {
if (!sessionId) {
return;
}
await revokeSession(sessionId);
}
export async function refreshUserSession({ refreshToken, userAgent, ipAddress }) {
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch (err) {
const error = new Error('Invalid or expired refresh token');
error.code = 'INVALID_REFRESH_TOKEN';
error.status = 401;
throw error;
}
const session = await getAuthSession(payload.sid);
if (!session) {
const error = new Error('Session not found or expired');
error.code = 'SESSION_NOT_FOUND';
error.status = 401;
throw error;
}
const user = await findUserById(session.user_id);
if (!user) {
const error = new Error('User not found');
error.code = 'USER_NOT_FOUND';
error.status = 401;
throw error;
}
const newRefreshToken = generateRefreshToken();
const newCsrfToken = generateCsrfToken();
const newRefreshTokenHash = hashToken(newRefreshToken);
const newCsrfTokenHash = hashToken(newCsrfToken);
const updatedSession = await rotateSessionTokens(
session.id,
newRefreshTokenHash,
newCsrfTokenHash
);
if (!updatedSession) {
const error = new Error('Failed to rotate session tokens');
error.code = 'SESSION_ROTATION_FAILED';
error.status = 500;
throw error;
}
const accessToken = signAccessToken(user, session.id);
const refreshTokenJwt = signRefreshToken({
sub: user.id,
sid: session.id,
});
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
accessToken,
refreshToken: refreshTokenJwt,
csrfToken: newCsrfToken,
};
}
export async function registerUser({ email, password, displayName, userAgent, ipAddress }) {
const { userExistsByEmail, createUser } = await import('../repositories/user.repository.js');
if (!email || !password) {
const err = new Error('Email and password are required');
err.statusCode = 400;
err.code = 'VALIDATION_ERROR';
throw err;
}
if (password.length < 6) {
const err = new Error('Password must be at least 6 characters');
err.statusCode = 400;
err.code = 'PASSWORD_TOO_SHORT';
throw err;
}
const exists = await userExistsByEmail(email);
if (exists) {
const err = new Error('User with this email already exists');
err.statusCode = 409;
err.code = 'EMAIL_ALREADY_EXISTS';
throw err;
}
const user = await createUser({ email, password, displayName });
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function loginWithTelegram({ telegramData, userAgent, ipAddress }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData;
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
// Verify auth_date freshness (max 1 day)
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
// Verify HMAC-SHA256 signature
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findUserByTelegramId, createUserFromTelegram } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
let user = await findUserByTelegramId(telegramId);
if (!user) {
const displayName = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || fields.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
}
if (user.status !== "active") {
throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" });
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
@@ -0,0 +1,285 @@
import { findUserByEmailAndPassword, touchLastLogin, findUserById, createUser, userExistsByEmail } from '../repositories/user.repository.js';
import { createAuthSession, revokeSession, getAuthSession, rotateSessionTokens } from '../repositories/session.repository.js';
import {
generateRefreshToken,
generateCsrfToken,
hashToken,
signAccessToken,
verifyRefreshToken,
signRefreshToken,
} from './token.service.js';
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 30);
function buildSessionExpiresAt() {
const dt = new Date();
dt.setDate(dt.getDate() + REFRESH_TTL_DAYS);
return dt;
}
export async function loginUser({
email,
password,
userAgent,
ipAddress,
}) {
const user = await findUserByEmailAndPassword(email, password);
if (!user) {
const err = new Error('Invalid email or password');
err.statusCode = 401;
err.code = 'INVALID_CREDENTIALS';
throw err;
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
await touchLastLogin(user.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: {
id: session.id,
expiresAt: session.expires_at,
},
accessToken,
refreshToken,
csrfToken,
};
}
export async function logoutUser({ sessionId }) {
if (!sessionId) {
return;
}
await revokeSession(sessionId);
}
export async function refreshUserSession({ refreshToken, userAgent, ipAddress }) {
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch (err) {
const error = new Error('Invalid or expired refresh token');
error.code = 'INVALID_REFRESH_TOKEN';
error.status = 401;
throw error;
}
const session = await getAuthSession(payload.sid);
if (!session) {
const error = new Error('Session not found or expired');
error.code = 'SESSION_NOT_FOUND';
error.status = 401;
throw error;
}
const user = await findUserById(session.user_id);
if (!user) {
const error = new Error('User not found');
error.code = 'USER_NOT_FOUND';
error.status = 401;
throw error;
}
const newRefreshToken = generateRefreshToken();
const newCsrfToken = generateCsrfToken();
const newRefreshTokenHash = hashToken(newRefreshToken);
const newCsrfTokenHash = hashToken(newCsrfToken);
const updatedSession = await rotateSessionTokens(
session.id,
newRefreshTokenHash,
newCsrfTokenHash
);
if (!updatedSession) {
const error = new Error('Failed to rotate session tokens');
error.code = 'SESSION_ROTATION_FAILED';
error.status = 500;
throw error;
}
const accessToken = signAccessToken(user, session.id);
const refreshTokenJwt = signRefreshToken({
sub: user.id,
sid: session.id,
});
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
accessToken,
refreshToken: refreshTokenJwt,
csrfToken: newCsrfToken,
};
}
export async function registerUser({ email, password, displayName, userAgent, ipAddress }) {
const { userExistsByEmail, createUser } = await import('../repositories/user.repository.js');
if (!email || !password) {
const err = new Error('Email and password are required');
err.statusCode = 400;
err.code = 'VALIDATION_ERROR';
throw err;
}
if (password.length < 6) {
const err = new Error('Password must be at least 6 characters');
err.statusCode = 400;
err.code = 'PASSWORD_TOO_SHORT';
throw err;
}
const exists = await userExistsByEmail(email);
if (exists) {
const err = new Error('User with this email already exists');
err.statusCode = 409;
err.code = 'EMAIL_ALREADY_EXISTS';
throw err;
}
const user = await createUser({ email, password, displayName });
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function loginWithTelegram({ telegramData, userAgent, ipAddress }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData;
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
// Verify auth_date freshness (max 1 day)
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
// Antifraud: require Telegram username (filters out throwaway / bot accounts)
if (!fields.username || String(fields.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
// Verify HMAC-SHA256 signature
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
let user = await findUserByTelegramId(telegramId);
if (!user) {
// Antifraud: limit 3 new Telegram signups per IP per 24h
const recentCount = await countTelegramSignupsByIpLast24h(ipAddress);
if (recentCount >= 3) {
throw Object.assign(new Error("Too many signups from this IP"), { statusCode: 429, code: "SIGNUP_RATE_LIMIT" });
}
const displayName = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || fields.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
await recordSignupAttempt({ ipAddress, provider: 'telegram', telegramId, userAgent });
}
if (user.status !== "active") {
throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" });
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
@@ -0,0 +1,326 @@
import { findUserByEmailAndPassword, touchLastLogin, findUserById, createUser, userExistsByEmail } from '../repositories/user.repository.js';
import { createAuthSession, revokeSession, getAuthSession, rotateSessionTokens } from '../repositories/session.repository.js';
import {
generateRefreshToken,
generateCsrfToken,
hashToken,
signAccessToken,
verifyRefreshToken,
signRefreshToken,
} from './token.service.js';
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 30);
function buildSessionExpiresAt() {
const dt = new Date();
dt.setDate(dt.getDate() + REFRESH_TTL_DAYS);
return dt;
}
export async function loginUser({
email,
password,
userAgent,
ipAddress,
}) {
const user = await findUserByEmailAndPassword(email, password);
if (!user) {
const err = new Error('Invalid email or password');
err.statusCode = 401;
err.code = 'INVALID_CREDENTIALS';
throw err;
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
await touchLastLogin(user.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: {
id: session.id,
expiresAt: session.expires_at,
},
accessToken,
refreshToken,
csrfToken,
};
}
export async function logoutUser({ sessionId }) {
if (!sessionId) {
return;
}
await revokeSession(sessionId);
}
export async function refreshUserSession({ refreshToken, userAgent, ipAddress }) {
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch (err) {
const error = new Error('Invalid or expired refresh token');
error.code = 'INVALID_REFRESH_TOKEN';
error.status = 401;
throw error;
}
const session = await getAuthSession(payload.sid);
if (!session) {
const error = new Error('Session not found or expired');
error.code = 'SESSION_NOT_FOUND';
error.status = 401;
throw error;
}
const user = await findUserById(session.user_id);
if (!user) {
const error = new Error('User not found');
error.code = 'USER_NOT_FOUND';
error.status = 401;
throw error;
}
const newRefreshToken = generateRefreshToken();
const newCsrfToken = generateCsrfToken();
const newRefreshTokenHash = hashToken(newRefreshToken);
const newCsrfTokenHash = hashToken(newCsrfToken);
const updatedSession = await rotateSessionTokens(
session.id,
newRefreshTokenHash,
newCsrfTokenHash
);
if (!updatedSession) {
const error = new Error('Failed to rotate session tokens');
error.code = 'SESSION_ROTATION_FAILED';
error.status = 500;
throw error;
}
const accessToken = signAccessToken(user, session.id);
const refreshTokenJwt = signRefreshToken({
sub: user.id,
sid: session.id,
});
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
accessToken,
refreshToken: refreshTokenJwt,
csrfToken: newCsrfToken,
};
}
export async function registerUser({ email, password, displayName, userAgent, ipAddress }) {
const { userExistsByEmail, createUser } = await import('../repositories/user.repository.js');
if (!email || !password) {
const err = new Error('Email and password are required');
err.statusCode = 400;
err.code = 'VALIDATION_ERROR';
throw err;
}
if (password.length < 6) {
const err = new Error('Password must be at least 6 characters');
err.statusCode = 400;
err.code = 'PASSWORD_TOO_SHORT';
throw err;
}
const exists = await userExistsByEmail(email);
if (exists) {
const err = new Error('User with this email already exists');
err.statusCode = 409;
err.code = 'EMAIL_ALREADY_EXISTS';
throw err;
}
const user = await createUser({ email, password, displayName });
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function loginWithTelegram({ telegramData, userAgent, ipAddress }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData;
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
// Verify auth_date freshness (max 1 day)
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
// Antifraud: require Telegram username (filters out throwaway / bot accounts)
if (!fields.username || String(fields.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
// Verify HMAC-SHA256 signature
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
let user = await findUserByTelegramId(telegramId);
if (!user) {
// Antifraud: limit 3 new Telegram signups per IP per 24h
const recentCount = await countTelegramSignupsByIpLast24h(ipAddress);
if (recentCount >= 3) {
throw Object.assign(new Error("Too many signups from this IP"), { statusCode: 429, code: "SIGNUP_RATE_LIMIT" });
}
const displayName = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || fields.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
await recordSignupAttempt({ ipAddress, provider: 'telegram', telegramId, userAgent });
}
if (user.status !== "active") {
throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" });
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function linkTelegramAccount({ userId, telegramData }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData || {};
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
if (!fields.username || String(fields.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findTelegramOwner, linkTelegramToUser } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
const owner = await findTelegramOwner(telegramId);
if (owner && owner.id !== userId) {
throw Object.assign(new Error("Telegram already linked to another account"), { statusCode: 409, code: "TELEGRAM_ALREADY_LINKED" });
}
await linkTelegramToUser(userId, telegramId);
return { ok: true, telegramId };
}
export async function unlinkTelegramAccount(userId) {
const { unlinkTelegramFromUser } = await import("../repositories/user.repository.js");
await unlinkTelegramFromUser(userId);
return { ok: true };
}
@@ -0,0 +1,481 @@
import { findUserByEmailAndPassword, touchLastLogin, findUserById, createUser, userExistsByEmail } from '../repositories/user.repository.js';
import { createAuthSession, revokeSession, getAuthSession, rotateSessionTokens } from '../repositories/session.repository.js';
import {
generateRefreshToken,
generateCsrfToken,
hashToken,
signAccessToken,
verifyRefreshToken,
signRefreshToken,
} from './token.service.js';
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 30);
function buildSessionExpiresAt() {
const dt = new Date();
dt.setDate(dt.getDate() + REFRESH_TTL_DAYS);
return dt;
}
export async function loginUser({
email,
password,
userAgent,
ipAddress,
}) {
const user = await findUserByEmailAndPassword(email, password);
if (!user) {
const err = new Error('Invalid email or password');
err.statusCode = 401;
err.code = 'INVALID_CREDENTIALS';
throw err;
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
await touchLastLogin(user.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: {
id: session.id,
expiresAt: session.expires_at,
},
accessToken,
refreshToken,
csrfToken,
};
}
export async function logoutUser({ sessionId }) {
if (!sessionId) {
return;
}
await revokeSession(sessionId);
}
export async function refreshUserSession({ refreshToken, userAgent, ipAddress }) {
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch (err) {
const error = new Error('Invalid or expired refresh token');
error.code = 'INVALID_REFRESH_TOKEN';
error.status = 401;
throw error;
}
const session = await getAuthSession(payload.sid);
if (!session) {
const error = new Error('Session not found or expired');
error.code = 'SESSION_NOT_FOUND';
error.status = 401;
throw error;
}
const user = await findUserById(session.user_id);
if (!user) {
const error = new Error('User not found');
error.code = 'USER_NOT_FOUND';
error.status = 401;
throw error;
}
const newRefreshToken = generateRefreshToken();
const newCsrfToken = generateCsrfToken();
const newRefreshTokenHash = hashToken(newRefreshToken);
const newCsrfTokenHash = hashToken(newCsrfToken);
const updatedSession = await rotateSessionTokens(
session.id,
newRefreshTokenHash,
newCsrfTokenHash
);
if (!updatedSession) {
const error = new Error('Failed to rotate session tokens');
error.code = 'SESSION_ROTATION_FAILED';
error.status = 500;
throw error;
}
const accessToken = signAccessToken(user, session.id);
const refreshTokenJwt = signRefreshToken({
sub: user.id,
sid: session.id,
});
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
accessToken,
refreshToken: refreshTokenJwt,
csrfToken: newCsrfToken,
};
}
export async function registerUser({ email, password, displayName, userAgent, ipAddress }) {
const { userExistsByEmail, createUser } = await import('../repositories/user.repository.js');
if (!email || !password) {
const err = new Error('Email and password are required');
err.statusCode = 400;
err.code = 'VALIDATION_ERROR';
throw err;
}
if (password.length < 6) {
const err = new Error('Password must be at least 6 characters');
err.statusCode = 400;
err.code = 'PASSWORD_TOO_SHORT';
throw err;
}
const exists = await userExistsByEmail(email);
if (exists) {
const err = new Error('User with this email already exists');
err.statusCode = 409;
err.code = 'EMAIL_ALREADY_EXISTS';
throw err;
}
const user = await createUser({ email, password, displayName });
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function loginWithTelegram({ telegramData, userAgent, ipAddress }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData;
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
// Verify auth_date freshness (max 1 day)
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
// Antifraud: require Telegram username (filters out throwaway / bot accounts)
if (!fields.username || String(fields.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
// Verify HMAC-SHA256 signature
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
let user = await findUserByTelegramId(telegramId);
if (!user) {
// Antifraud: limit 3 new Telegram signups per IP per 24h
const recentCount = await countTelegramSignupsByIpLast24h(ipAddress);
if (recentCount >= 3) {
throw Object.assign(new Error("Too many signups from this IP"), { statusCode: 429, code: "SIGNUP_RATE_LIMIT" });
}
const displayName = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || fields.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
await recordSignupAttempt({ ipAddress, provider: 'telegram', telegramId, userAgent });
}
if (user.status !== "active") {
throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" });
}
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const refreshTokenHash = hashToken(refreshToken);
const csrfTokenHash = hashToken(csrfToken);
const session = await createAuthSession({
userId: user.id,
refreshTokenHash,
csrfTokenHash,
userAgent,
ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
status: user.status,
},
session: { id: session.id, expiresAt: session.expires_at },
accessToken,
refreshToken,
csrfToken,
};
}
export async function linkTelegramAccount({ userId, telegramData }) {
const crypto = await import("crypto");
const { hash, ...fields } = telegramData || {};
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" });
const authDate = Number(fields.auth_date);
if (!authDate || Date.now() / 1000 - authDate > 86400) {
throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" });
}
if (!fields.username || String(fields.username).trim().length < 3) {
throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" });
}
const dataCheckString = Object.keys(fields)
.sort()
.filter(k => fields[k] !== undefined && fields[k] !== null)
.map(k => `${k}=${fields[k]}`)
.join("\n");
const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest();
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
if (hmac !== hash) {
throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" });
}
const { findTelegramOwner, linkTelegramToUser } = await import("../repositories/user.repository.js");
const telegramId = Number(fields.id);
const owner = await findTelegramOwner(telegramId);
if (owner && owner.id !== userId) {
throw Object.assign(new Error("Telegram already linked to another account"), { statusCode: 409, code: "TELEGRAM_ALREADY_LINKED" });
}
await linkTelegramToUser(userId, telegramId);
return { ok: true, telegramId };
}
export async function unlinkTelegramAccount(userId) {
const { unlinkTelegramFromUser } = await import("../repositories/user.repository.js");
await unlinkTelegramFromUser(userId);
return { ok: true };
}
// ─── Bot deeplink login flow ─────────────────────────────────────────────────
export async function startTelegramLoginFlow({ intent, userId, ipAddress, userAgent }) {
const crypto = await import("crypto");
const { createTgLoginToken, countTgStartsByIpLastMinutes } = await import("../repositories/tg-login.repository.js");
// Антифрод: 10 стартов с одного IP за 10 минут
const recent = await countTgStartsByIpLastMinutes(ipAddress, 10);
if (recent >= 10) {
throw Object.assign(new Error("Too many login attempts. Try again later."),
{ statusCode: 429, code: "TG_START_RATE_LIMIT" });
}
// Telegram /start payload: до 64 base64url-символов
const token = crypto.randomBytes(24).toString("base64url");
const created = await createTgLoginToken({ token, intent, userId, ipAddress, userAgent });
const botUsername = process.env.TELEGRAM_BOT_USERNAME || "One_Click_Auth_bot";
const url = `https://t.me/${botUsername}?start=${token}`;
return { token, url, expiresAt: created.expiresAt, ttlSec: created.ttlSec };
}
export async function confirmTelegramFromBot({ token, telegramUser }) {
const { findTgLoginToken, markTgTokenConfirmed, markTgTokenError } = await import("../repositories/tg-login.repository.js");
const {
findUserByTelegramId, createUserFromTelegram,
countTelegramSignupsByIpLast24h, recordSignupAttempt,
findUserById, findTelegramOwner, linkTelegramToUser,
} = await import("../repositories/user.repository.js");
const row = await findTgLoginToken(token);
if (!row) return { ok: false, code: "TOKEN_NOT_FOUND", message: "Ссылка не найдена или устарела." };
if (row.status !== "pending") return { ok: false, code: "TOKEN_NOT_PENDING", message: "Эта ссылка уже использована. Запросите новую на сайте." };
if (new Date(row.expires_at) < new Date()) {
await markTgTokenError(token, "EXPIRED");
return { ok: false, code: "EXPIRED", message: "Ссылка истекла. Запросите новую на сайте." };
}
// Антифрод: требуем username
if (!telegramUser.username || String(telegramUser.username).trim().length < 3) {
await markTgTokenError(token, "USERNAME_REQUIRED");
return { ok: false, code: "USERNAME_REQUIRED", message: "У вашего Telegram-аккаунта должно быть @username." };
}
const telegramId = Number(telegramUser.id);
if (row.intent === "link") {
if (!row.user_id) {
await markTgTokenError(token, "NO_USER");
return { ok: false, code: "NO_USER", message: "Авторизация не найдена. Войдите на сайт и попробуйте снова." };
}
const owner = await findTelegramOwner(telegramId);
if (owner && owner.id !== row.user_id) {
await markTgTokenError(token, "TELEGRAM_ALREADY_LINKED");
return { ok: false, code: "TELEGRAM_ALREADY_LINKED", message: "Этот Telegram уже привязан к другому аккаунту." };
}
await linkTelegramToUser(row.user_id, telegramId);
await markTgTokenConfirmed(token, {
telegramId,
telegramUsername: telegramUser.username,
telegramFirstName: telegramUser.first_name || null,
telegramLastName: telegramUser.last_name || null,
userId: row.user_id,
});
return { ok: true, message: "Готово! Telegram привязан. Возвращайтесь на сайт." };
}
// intent === "login"
let user = await findUserByTelegramId(telegramId);
if (!user) {
const recent = await countTelegramSignupsByIpLast24h(row.ip_address);
if (recent >= 3) {
await markTgTokenError(token, "SIGNUP_RATE_LIMIT");
return { ok: false, code: "SIGNUP_RATE_LIMIT", message: "Слишком много регистраций с этого IP. Попробуйте позже." };
}
const displayName = [telegramUser.first_name, telegramUser.last_name].filter(Boolean).join(" ") || telegramUser.username || null;
const email = `tg_${telegramId}@telegram.local`;
user = await createUserFromTelegram({ telegramId, displayName, email });
await recordSignupAttempt({ ipAddress: row.ip_address, provider: "telegram", telegramId, userAgent: row.user_agent });
}
if (user.status !== "active") {
await markTgTokenError(token, "ACCOUNT_BLOCKED");
return { ok: false, code: "ACCOUNT_BLOCKED", message: "Аккаунт заблокирован." };
}
await markTgTokenConfirmed(token, {
telegramId,
telegramUsername: telegramUser.username,
telegramFirstName: telegramUser.first_name || null,
telegramLastName: telegramUser.last_name || null,
userId: user.id,
});
return { ok: true, message: "Готово! Можете возвращаться на сайт — вы уже вошли." };
}
export async function pollTelegramLoginFlow({ token, userAgent, ipAddress }) {
const { findTgLoginToken, consumeTgToken } = await import("../repositories/tg-login.repository.js");
const { findUserById } = await import("../repositories/user.repository.js");
const row = await findTgLoginToken(token);
if (!row) return { status: "not_found" };
if (row.status === "pending") {
if (new Date(row.expires_at) < new Date()) return { status: "expired" };
return { status: "pending" };
}
if (row.status === "error") {
return { status: "error", code: row.error_code };
}
if (row.status === "consumed") {
return { status: "consumed" };
}
if (row.status !== "confirmed") {
return { status: row.status };
}
// confirmed → consume + maybe issue session
const consumed = await consumeTgToken(token);
if (!consumed) return { status: "consumed" };
if (row.intent === "link") {
return { status: "linked" };
}
// login: создаём сессию
const user = await findUserById(row.user_id);
if (!user) return { status: "error", code: "USER_NOT_FOUND" };
const refreshToken = generateRefreshToken();
const csrfToken = generateCsrfToken();
const session = await createAuthSession({
userId: user.id,
refreshTokenHash: hashToken(refreshToken),
csrfTokenHash: hashToken(csrfToken),
userAgent, ipAddress,
expiresAt: buildSessionExpiresAt(),
});
const accessToken = signAccessToken(user, session.id);
return {
status: "authenticated",
user: {
id: user.id, email: user.email, displayName: user.display_name,
role: user.role, status: user.status,
},
accessToken, refreshToken, csrfToken,
sessionExpiresAt: session.expires_at,
};
}
+352
View File
@@ -0,0 +1,352 @@
import { userFileRepository } from '../repositories/user-file.repository.js';
import {
uploadFile as s3UploadFile,
getPresignedUrl as s3GetPresignedUrl,
deleteFile as s3DeleteFile,
getImageInputKey,
getVideoInputKey,
createMultipartUpload as s3CreateMultipartUpload,
getPresignedPartUrl as s3GetPresignedPartUrl,
completeMultipartUpload as s3CompleteMultipartUpload,
abortMultipartUpload as s3AbortMultipartUpload,
} from './s3.service.js';
/**
* Сервис для управления файлами пользователей
*/
export const fileService = {
/**
* Загрузить изображение в S3
*/
async uploadImage({ userId, file, folder = 'images_input', generationUuid = null, generationStepId = null }) {
console.log('[fileService.uploadImage] userId:', userId, 'file:', file?.originalname, 'folder:', folder);
const s3Key = getImageInputKey(file.originalname, userId, folder);
await s3UploadFile(s3Key, file.buffer, file.mimetype);
const fileRecord = await userFileRepository.create({
userId,
s3Key,
originalFilename: file.originalname,
fileSize: file.size,
contentType: file.mimetype,
fileType: 'image',
folder,
generationUuid,
generationStepId,
});
return fileRecord;
},
/**
* Загрузить видео в S3
*/
async uploadVideo({ userId, file, folder = 'videos_input', generationUuid = null, generationStepId = null }) {
console.log('[fileService.uploadVideo] userId:', userId, 'file:', file?.originalname, 'folder:', folder);
const s3Key = getVideoInputKey(file.originalname, userId, folder);
await s3UploadFile(s3Key, file.buffer, file.mimetype);
const fileRecord = await userFileRepository.create({
userId,
s3Key,
originalFilename: file.originalname,
fileSize: file.size,
contentType: file.mimetype,
fileType: 'video',
folder,
generationUuid,
generationStepId,
});
return fileRecord;
},
/**
* Инициировать multipart upload для прямой загрузки в S3
*/
async initMultipartUpload({ userId, filename, contentType, fileSize, folder = 'videos_input', generationUuid = null, generationStepId = null }) {
console.log('[fileService.initMultipartUpload] userId:', userId, 'filename:', filename, 'size:', fileSize);
const s3Key = getVideoInputKey(filename, userId, folder);
// Инициируем multipart upload в S3
const { uploadId } = await s3CreateMultipartUpload(s3Key, contentType);
// Создаём запись в БД со статусом "uploading" и uploadId
const fileRecord = await userFileRepository.create({
userId,
s3Key,
originalFilename: filename,
fileSize,
contentType,
fileType: 'video',
folder,
generationUuid,
generationStepId,
status: 'uploading',
uploadId, // Сохраняем uploadId в БД
});
// Вычисляем количество частей (10MB на часть - оптимизировано для скорости)
const PART_SIZE = 10 * 1024 * 1024; // 10MB
const partCount = Math.ceil(fileSize / PART_SIZE);
// Создаём presigned URLs для всех частей
const parts = [];
for (let i = 1; i <= partCount; i++) {
const presignedUrl = await s3GetPresignedPartUrl(s3Key, uploadId, i, 3600); // 1 час на загрузку части
parts.push({
partNumber: i,
presignedUrl,
});
}
return {
fileId: fileRecord.id,
s3Key,
uploadId,
parts,
partCount,
};
},
/**
* Завершить multipart upload
*/
async completeMultipartUpload({ fileId, userId, uploadId, parts }) {
console.log('[fileService.completeMultipartUpload] fileId:', fileId, 'uploadId:', uploadId);
// Проверяем владение файлом
const fileRecord = await userFileRepository.findByIdAndOwner(fileId, userId);
if (!fileRecord) {
const error = new Error('Файл не найден');
error.code = 'FILE_NOT_FOUND';
throw error;
}
// Завершаем multipart upload в S3
const result = await s3CompleteMultipartUpload(fileRecord.s3_key, uploadId, parts);
// Обновляем статус файла в БД
await userFileRepository.update(fileId, {
status: 'uploaded',
updated_at: new Date(),
});
return {
fileId: fileRecord.id,
s3Key: fileRecord.s3_key,
location: result.location,
etag: result.etag,
};
},
/**
* Отменить multipart upload
*/
async abortMultipartUpload({ fileId, userId, uploadId }) {
console.log('[fileService.abortMultipartUpload] fileId:', fileId);
const fileRecord = await userFileRepository.findByIdAndOwner(fileId, userId);
if (!fileRecord) {
const error = new Error('Файл не найден');
error.code = 'FILE_NOT_FOUND';
throw error;
}
// Отменяем multipart upload в S3
await s3AbortMultipartUpload(fileRecord.s3_key, uploadId);
// Удаляем запись из БД
await userFileRepository.delete(fileId);
return { fileId, aborted: true };
},
/**
* Получить URL для файла с проверкой владения
* Если файл в публичной папке — возвращается прямой URL, иначе presigned
*/
async getPresignedUrl({ userId, fileId, s3Key, expiresIn }) {
let fileRecord;
// Поиск файла по ID или по ключу
if (fileId) {
fileRecord = await userFileRepository.findByIdAndOwner(fileId, userId);
} else if (s3Key) {
fileRecord = await userFileRepository.findByKeyAndOwner(s3Key, userId);
}
if (!fileRecord) {
const error = new Error('Файл не найден или у вас нет доступа к нему');
error.code = 'FILE_NOT_FOUND';
error.statusCode = 404;
throw error;
}
// Для публичных папок возвращаем прямой URL через nginx
const publicFolders = ['images_input', 'videos_input', 'videos_output'];
const isPublic = publicFolders.includes(fileRecord.folder);
let url;
if (isPublic) {
// Прямой URL через nginx прокси
url = `/files/${fileRecord.s3_key}`;
} else {
// Presigned URL для приватных файлов
url = await s3GetPresignedUrl(fileRecord.s3_key, expiresIn);
}
return {
id: fileRecord.id,
s3Key: fileRecord.s3_key,
originalFilename: fileRecord.original_filename,
contentType: fileRecord.content_type,
fileSize: fileRecord.file_size,
url,
isPublic,
expiresIn: isPublic ? null : (expiresIn || 3600),
};
},
/**
* Получить список файлов пользователя
*/
async listUserFiles(userId, options = {}) {
const defaultOptions = {
fileType: 'image',
folder: 'images_input',
limit: 50,
offset: 0,
};
const mergedOptions = { ...defaultOptions, ...options };
const [files, total] = await Promise.all([
userFileRepository.findByUser(userId, mergedOptions),
userFileRepository.countByUser(userId, mergedOptions),
]);
return {
files: files.map((f) => ({
id: f.id,
s3Key: f.s3_key,
originalFilename: f.original_filename,
contentType: f.content_type,
fileSize: f.file_size,
fileType: f.file_type,
folder: f.folder,
createdAt: f.created_at,
})),
total,
limit: mergedOptions.limit,
offset: mergedOptions.offset,
};
},
/**
* Удалить файл пользователя (из S3 и БД)
*/
async deleteFile({ userId, fileId, s3Key }) {
let fileRecord;
// Поиск файла с проверкой владения
if (fileId) {
fileRecord = await userFileRepository.deleteByIdAndOwner(fileId, userId);
} else if (s3Key) {
fileRecord = await userFileRepository.deleteByKeyAndOwner(s3Key, userId);
}
if (!fileRecord) {
const error = new Error('Файл не найден или у вас нет доступа к нему');
error.code = 'FILE_NOT_FOUND';
error.statusCode = 404;
throw error;
}
// Удаление из S3
try {
await s3DeleteFile(fileRecord.s3_key);
} catch (err) {
// Логируем ошибку, но не прерываем процесс
// Файл уже удалён из БД
console.error(`Ошибка удаления файла из S3: ${fileRecord.s3_key}`, err);
}
return {
id: fileRecord.id,
s3Key: fileRecord.s3_key,
deleted: true,
};
},
/**
* Получить статистику по файлам пользователя
*/
async getFileStats(userId) {
const { pool } = await import('../db.js');
const [imageCount, videoCount, totalSize] = await Promise.all([
userFileRepository.countByUser(userId, { fileType: 'image' }),
userFileRepository.countByUser(userId, { fileType: 'video' }),
(async () => {
const query = `
SELECT COALESCE(SUM(file_size), 0) as total
FROM uno_bff.user_files
WHERE user_id = $1
`;
const result = await pool.query(query, [userId]);
return parseInt(result.rows[0].total, 10);
})(),
]);
return {
imageCount,
videoCount,
totalCount: imageCount + videoCount,
totalSizeBytes: totalSize,
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
};
},
/**
* Получить файлы генерации
*/
async getGenerationFiles(generationUuid, userId) {
const files = await userFileRepository.findByGeneration(generationUuid, userId);
return files.map((f) => ({
id: f.id,
s3Key: f.s3_key,
originalFilename: f.original_filename,
contentType: f.content_type,
fileSize: f.file_size,
fileType: f.file_type,
folder: f.folder,
generationUuid: f.generation_uuid,
generationStepId: f.generation_step_id,
createdAt: f.created_at,
}));
},
/**
* Получить файлы шага генерации
*/
async getGenerationStepFiles(generationUuid, stepId, userId) {
const files = await userFileRepository.findByGenerationStep(generationUuid, stepId, userId);
return files.map((f) => ({
id: f.id,
s3Key: f.s3_key,
originalFilename: f.original_filename,
contentType: f.content_type,
fileSize: f.file_size,
fileType: f.file_type,
folder: f.folder,
generationUuid: f.generation_uuid,
generationStepId: f.generation_step_id,
createdAt: f.created_at,
}));
},
};
+309
View File
@@ -0,0 +1,309 @@
import { env } from '../config/env.js';
import {
getGenerationByUuidForUser,
getGenerationSteps,
updateGenerationResult,
getGenerationMetaByUuid,
} from '../repositories/scenario.repository.js';
/**
* Валидация формата результата от n8n
*
* Ожидаемый формат body (массив файлов):
* {
* files: [
* {
* contentType: 'image' | 'video',
* url: string, // ссылка на файл (GET запрос)
* thumbnailUrl?: string,
* duration?: number,
* width?: number,
* height?: number,
* size?: number,
* format?: string
* }
* ]
* }
*/
function validateResultBody(body) {
const errors = [];
if (!body || typeof body !== 'object') {
errors.push('Body must be an object');
return { valid: false, errors };
}
// files: обязательный массив
if (!body.files) {
errors.push('files is required');
} else if (!Array.isArray(body.files)) {
errors.push('files must be an array');
} else if (body.files.length === 0) {
errors.push('files array cannot be empty');
} else {
// Валидация каждого файла
body.files.forEach((file, index) => {
const prefix = `files[${index}]`;
// contentType
if (!file.contentType) {
errors.push(`${prefix}.contentType is required`);
} else if (!['image', 'video'].includes(file.contentType)) {
errors.push(`${prefix}.contentType must be 'image' or 'video'`);
}
// url: обязателен
if (!file.url) {
errors.push(`${prefix}.url is required`);
} else if (typeof file.url !== 'string' || file.url.trim() === '') {
errors.push(`${prefix}.url must be a non-empty string`);
} else {
try {
new URL(file.url);
} catch {
errors.push(`${prefix}.url must be a valid URL`);
}
}
// thumbnailUrl: опционален
if (file.thumbnailUrl !== undefined && file.thumbnailUrl !== null) {
if (typeof file.thumbnailUrl !== 'string') {
errors.push(`${prefix}.thumbnailUrl must be a string`);
} else {
try {
new URL(file.thumbnailUrl);
} catch {
errors.push(`${prefix}.thumbnailUrl must be a valid URL`);
}
}
}
// duration: опционален, только для видео
if (file.duration !== undefined && file.duration !== null) {
if (typeof file.duration !== 'number') {
errors.push(`${prefix}.duration must be a number`);
} else if (file.duration < 0) {
errors.push(`${prefix}.duration must be >= 0`);
}
}
// width/height: опциональны
if (file.width !== undefined && file.width !== null) {
if (!Number.isInteger(file.width) || file.width <= 0) {
errors.push(`${prefix}.width must be a positive integer`);
}
}
if (file.height !== undefined && file.height !== null) {
if (!Number.isInteger(file.height) || file.height <= 0) {
errors.push(`${prefix}.height must be a positive integer`);
}
}
// size: опционален
if (file.size !== undefined && file.size !== null) {
if (typeof file.size !== 'number' || file.size < 0) {
errors.push(`${prefix}.size must be a non-negative number`);
}
}
// format: опционален
if (file.format !== undefined && file.format !== null) {
if (typeof file.format !== 'string' || file.format.trim() === '') {
errors.push(`${prefix}.format must be a non-empty string`);
}
}
});
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Получить результат генерации для пользователя
* Формат ответа для фронта:
* {
* generationUuid: string,
* scenarioId: string,
* scenarioName: string,
* status: 'running' | 'completed' | 'failed' | 'waiting_for_input',
* files: Array<{
* id: string,
* contentType: 'image' | 'video',
* url: string,
* thumbnailUrl?: string,
* duration?: number,
* width?: number,
* height?: number,
* size?: number,
* format?: string
* }>,
* steps: [...],
* startedAt: date,
* finishedAt: date
* }
*/
export async function getResultForUser({ userId, generationUuid }) {
const generation = await getGenerationByUuidForUser(generationUuid, userId);
if (!generation) {
return null;
}
const steps = await getGenerationSteps(generationUuid);
// Преобразуем result_payload в формат для фронта
const files = parseResultPayload(generation.result_payload);
return {
generationUuid: generation.generation_uuid,
scenarioId: generation.scenario_id,
scenarioName: generation.scenario_name,
status: generation.status,
files,
steps,
startedAt: generation.started_at,
finishedAt: generation.finished_at,
requestPayload: generation.request_payload,
};
}
/**
* Сохранить результат в БД
*/
export async function saveResultToDB(generationUuid, files) {
const resultPayload = { files };
return updateGenerationResult(generationUuid, resultPayload, 'completed');
}
/**
* Получить метаданные генерации (только scenarioId и scenarioName)
*/
export async function getGenerationMeta({ userId, generationUuid }) {
const generation = await getGenerationMetaByUuid(generationUuid, userId);
if (!generation) {
return null;
}
return {
scenarioId: generation.scenario_id,
scenarioName: generation.scenario_name,
};
}
/**
* Парсит result_payload в массив файлов
*/
function parseResultPayload(resultPayload) {
if (!resultPayload || !resultPayload.files) {
return [];
}
return resultPayload.files.map((file, index) => ({
id: `file-${index}`,
contentType: file.contentType,
url: file.url,
thumbnailUrl: file.thumbnailUrl,
duration: file.duration,
width: file.width,
height: file.height,
size: file.size,
format: file.format,
}));
}
/**
* Обработать результат от n8n
*/
export async function processResultFromN8n({ generationUuid, userId, scenarioId, resultBody }) {
// Проверяем, что generation существует и принадлежит пользователю
const generation = await getGenerationByUuidForUser(generationUuid, userId);
if (!generation) {
const error = new Error(`Generation '${generationUuid}' not found or does not belong to user '${userId}'`);
error.code = 'GENERATION_NOT_FOUND';
error.status = 404;
throw error;
}
// Валидация результата
const validation = validateResultBody(resultBody);
if (!validation.valid) {
const error = new Error(`Invalid result format: ${validation.errors.join(', ')}`);
error.code = 'INVALID_RESULT_FORMAT';
error.status = 400;
error.details = validation.errors;
throw error;
}
// Сохраняем результат в БД
const updatedGeneration = await updateGenerationResult(generationUuid, resultBody, 'completed');
return {
generationUuid: updatedGeneration.generation_uuid,
status: updatedGeneration.status,
files: parseResultPayload(updatedGeneration.result_payload),
};
}
/**
* Вызов n8n webhook для получения результата
* stepData - данные из response_payload шага (taskId, recordId и т.д.)
*/
export async function fetchResultFromN8n({ generationUuid, userId, scenarioId, stepData = {} }) {
// Вызываем webhook без generationUuid в пути (передаём в meta/body)
const n8nUrl = `${env.N8N_BASE_URL}/webhook/result`;
const payload = {
meta: {
generationUuid,
userId,
scenarioId,
stepData,
},
body: stepData,
};
console.log('[fetchResultFromN8n] Calling:', n8nUrl);
console.log('[fetchResultFromN8n] Payload:', JSON.stringify(payload, null, 2));
try {
const response = await fetch(n8nUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
console.log('[fetchResultFromN8n] Response status:', response.status, response.statusText);
const responseText = await response.text();
console.log('[fetchResultFromN8n] Response body:', responseText);
if (!response.ok) {
const error = new Error(`n8n webhook failed: ${response.status} ${response.statusText}`);
error.code = 'N8N_WEBHOOK_ERROR';
error.status = response.status;
error.n8nResponse = responseText;
throw error;
}
const data = JSON.parse(responseText);
console.log('[fetchResultFromN8n] Parsed data:', data);
return data;
} catch (err) {
console.error('[fetchResultFromN8n] Error:', err.message);
if (err.code === 'N8N_WEBHOOK_ERROR') {
throw err;
}
const error = new Error(`Failed to call n8n webhook: ${err.message}`);
error.code = 'N8N_CONNECTION_ERROR';
error.status = 503;
throw error;
}
}
+172
View File
@@ -0,0 +1,172 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { env } from '../config/env.js';
// S3 client for internal server-to-server communication
const s3Client = new S3Client({
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY,
secretAccessKey: env.S3_SECRET_KEY,
},
region: 'us-east-1',
forcePathStyle: true,
// Оптимизация для больших файлов
requestHandler: {
connectionTimeout: 300000, // 5 минут
socketTimeout: 600000, // 10 минут
},
});
// S3 client for generating presigned URLs for browser access
// Uses the same credentials but will generate URLs with public endpoint
const s3ClientForPresigned = new S3Client({
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY,
secretAccessKey: env.S3_SECRET_KEY,
},
region: 'us-east-1',
forcePathStyle: true,
});
export async function uploadFile(key, body, contentType) {
const command = new PutObjectCommand({
Bucket: env.S3_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
});
await s3Client.send(command);
return key;
}
export async function getPresignedUrl(key, expiresIn = env.S3_PRESIGNED_URL_EXPIRES_IN) {
const command = new GetObjectCommand({
Bucket: env.S3_BUCKET,
Key: key,
});
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn });
return signedUrl;
}
export async function deleteFile(key) {
const command = new DeleteObjectCommand({
Bucket: env.S3_BUCKET,
Key: key,
});
await s3Client.send(command);
return true;
}
export function getImageInputKey(filename, userId, folder = env.S3_IMAGES_INPUT_FOLDER) {
const timestamp = Date.now();
const ext = filename.split('.').pop() || 'jpg';
return `${folder}/${userId}/${timestamp}-${filename}`;
}
export function getVideoInputKey(filename, userId, folder = 'videos_input') {
const timestamp = Date.now();
const ext = filename.split('.').pop() || 'mp4';
return `${folder}/${userId}/${timestamp}-${filename}`;
}
// ──────────────────────────────
// Multipart Upload для прямой загрузки в S3
// ──────────────────────────────
/**
* Инициировать multipart upload
*/
export async function createMultipartUpload(key, contentType) {
const command = new CreateMultipartUploadCommand({
Bucket: env.S3_BUCKET,
Key: key,
ContentType: contentType,
});
const result = await s3Client.send(command);
return {
uploadId: result.UploadId,
key,
};
}
/**
* Создать presigned URL для загрузки части файла
* Возвращает URL для прямой загрузки из браузера через nginx proxy
*/
export async function getPresignedPartUrl(key, uploadId, partNumber, expiresIn = 3600) {
const command = new UploadPartCommand({
Bucket: env.S3_BUCKET,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
});
// Для presigned URL используем endpoint который совпадает с public endpoint
// Браузер будет отправлять запрос на https://uno-click.pip-test.ru/s3-upload/uno-click/
// Нginx будет проксировать на minio:9000/uno-click/
// MinIO проверяет подпись используя только путь и query параметры, не host
const signedUrl = await getSignedUrl(s3ClientForPresigned, command, { expiresIn });
// Заменяем внутренний endpoint на публичный для доступа из браузера
// Превращаем http://minio:9000/uno-click/... в https://uno-click.pip-test.ru/s3-upload/uno-click/...
const publicUrl = signedUrl.replace(
`${env.S3_ENDPOINT}/${env.S3_BUCKET}/`,
env.S3_PUBLIC_ENDPOINT
);
return publicUrl;
}
/**
* Завершить multipart upload
*/
export async function completeMultipartUpload(key, uploadId, parts) {
const command = new CompleteMultipartUploadCommand({
Bucket: env.S3_BUCKET,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.map((p, index) => ({
PartNumber: index + 1,
ETag: p.ETag,
})),
},
});
const result = await s3Client.send(command);
return {
key,
location: result.Location,
bucket: result.Bucket,
etag: result.ETag,
};
}
/**
* Отменить multipart upload
*/
export async function abortMultipartUpload(key, uploadId) {
const command = new AbortMultipartUploadCommand({
Bucket: env.S3_BUCKET,
Key: key,
UploadId: uploadId,
});
await s3Client.send(command);
return true;
}
+322
View File
@@ -0,0 +1,322 @@
import { env } from '../config/env.js';
import {
getUserScenarioPermission,
getScenarioById,
getScenarioStep,
getFirstScenarioStep,
createGeneration,
updateGeneration,
getGenerationByUuid,
} from '../repositories/scenario.repository.js';
import { pool } from '../db.js';
// Экспорт для использования в routes
export { getScenarioStep };
/**
* Валидация данных по JSON Schema (строгая - все required поля)
*/
function validateInputSchema(inputSchema, data) {
const errors = [];
if (!inputSchema || inputSchema === '{}') {
return { valid: true, errors: [] };
}
const schema = typeof inputSchema === 'string' ? JSON.parse(inputSchema) : inputSchema;
// Проверка required полей
if (schema.required && Array.isArray(schema.required)) {
for (const field of schema.required) {
if (data[field] === undefined || data[field] === null) {
errors.push(`Missing required field: ${field}`);
}
}
}
// Проверка типов полей
if (schema.properties) {
for (const [field, fieldSchema] of Object.entries(schema.properties)) {
const value = data[field];
if (value === undefined || value === null) {
continue; // already checked in required
}
if (fieldSchema.type === 'string' && typeof value !== 'string') {
errors.push(`Field '${field}' must be a string`);
} else if (fieldSchema.type === 'boolean' && typeof value !== 'boolean') {
errors.push(`Field '${field}' must be a boolean`);
} else if (fieldSchema.type === 'number' && typeof value !== 'number') {
errors.push(`Field '${field}' must be a number`);
} else if (fieldSchema.type === 'integer' && (!Number.isInteger(value))) {
errors.push(`Field '${field}' must be an integer`);
} else if (fieldSchema.type === 'array' && !Array.isArray(value)) {
errors.push(`Field '${field}' must be an array`);
} else if (fieldSchema.type === 'object' && (typeof value !== 'object' || Array.isArray(value))) {
errors.push(`Field '${field}' must be an object`);
}
// Проверка minLength для string
if (fieldSchema.type === 'string' && typeof value === 'string' && fieldSchema.minLength !== undefined) {
if (value.length < fieldSchema.minLength) {
errors.push(`Field '${field}' must have at least ${fieldSchema.minLength} characters`);
}
}
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Проверка прав доступа на запуск сценария
*/
export async function assertUserCanStartScenario({ userId, scenarioId }) {
const scenario = await getScenarioById(scenarioId);
if (!scenario) {
const error = new Error(`Scenario '${scenarioId}' not found`);
error.code = 'SCENARIO_NOT_FOUND';
error.status = 404;
throw error;
}
if (scenario.status !== 'active') {
const error = new Error(`Scenario '${scenarioId}' is not active (status: ${scenario.status})`);
error.code = 'SCENARIO_NOT_ACTIVE';
error.status = 403;
throw error;
}
// Публичные сценарии доступны всем авторизованным пользователям
if (!scenario.is_public) {
const permission = await getUserScenarioPermission(userId, scenarioId);
if (!permission || !permission.can_start) {
const error = new Error(`User '${userId}' does not have permission to start scenario '${scenarioId}'`);
error.code = 'SCENARIO_ACCESS_DENIED';
error.status = 403;
throw error;
}
}
return true;
}
/**
* Проверка прав доступа на выполнение шага
*/
export async function assertUserCanExecuteStep({ userId, scenarioId, stepId }) {
const scenario = await getScenarioById(scenarioId);
if (!scenario) {
const error = new Error(`Scenario '${scenarioId}' not found`);
error.code = 'SCENARIO_NOT_FOUND';
error.status = 404;
throw error;
}
const step = await getScenarioStep(scenarioId, stepId);
if (!step) {
const error = new Error(`Step '${stepId}' not found in scenario '${scenarioId}'`);
error.code = 'STEP_NOT_FOUND';
error.status = 404;
throw error;
}
if (step.status !== 'active') {
const error = new Error(`Step '${stepId}' is not active (status: ${step.status})`);
error.code = 'STEP_NOT_ACTIVE';
error.status = 403;
throw error;
}
// Публичные сценарии доступны всем авторизованным пользователям
if (!scenario.is_public) {
const permission = await getUserScenarioPermission(userId, scenarioId);
if (!permission || !permission.can_execute) {
const error = new Error(`User '${userId}' does not have permission to execute steps in scenario '${scenarioId}'`);
error.code = 'SCENARIO_ACCESS_DENIED';
error.status = 403;
throw error;
}
}
return true;
}
/**
* Запуск сценария
*/
export async function startScenario({ userId, scenarioId, input, user }) {
// Валидация input для start - body должен быть пустым или минимальным
// Для start сценария проверяем, что нет лишних данных (body должен быть пустым {})
if (input && Object.keys(input).length > 0) {
const error = new Error('Start scenario body must be empty');
error.code = 'INVALID_START_BODY';
error.status = 400;
throw error;
}
const firstStep = await getFirstScenarioStep(scenarioId);
if (!firstStep) {
const error = new Error(`No active steps found in scenario '${scenarioId}'`);
error.code = 'NO_ACTIVE_STEPS';
error.status = 400;
throw error;
}
// Создаем generation
const generation = await createGeneration({
userId,
scenarioId,
authSessionId: user?.sessionId || null,
requestPayload: {},
currentStepId: firstStep.step_id,
});
// Формируем payload для n8n
// generationUuid НЕ отправляем - n8n должен сгенерировать свой и вернуть в ответе
const n8nPayload = {
meta: {
userId: user.id,
userEmail: user.email,
sessionId: user.sessionId,
scenarioId,
},
body: {},
};
// Вызов n8n webhook
const n8nUrl = `${env.N8N_BASE_URL}/webhook/scenario/${scenarioId}/start`;
const n8nResponse = await callN8nWebhook(n8nUrl, n8nPayload);
// Обновляем generation с external_run_id и generation_uuid от n8n
const updateData = {};
if (n8nResponse?.runId) {
updateData.externalRunId = n8nResponse.runId;
}
if (n8nResponse?.generationUuid) {
// Если n8n вернул свой generationUuid, используем его
// Но это маловероятно - скорее всего мы уже сгенерировали свой
updateData.generationUuid = n8nResponse.generationUuid;
}
if (Object.keys(updateData).length > 0) {
await updateGeneration(generation.generation_uuid, updateData);
}
return {
generationUuid: generation.generation_uuid,
status: generation.status,
currentStepId: firstStep.step_id,
};
}
/**
* Выполнение шага сценария
* BFF только валидирует входные данные и проксирует запрос в n8n.
* n8n отвечает за запись в БД (generation_steps, generations).
*/
export async function executeStep({ userId, scenarioId, stepId, input, user }) {
const step = await getScenarioStep(scenarioId, stepId);
// Валидация input по input_schema из БД
const validation = validateInputSchema(step.input_schema, input);
if (!validation.valid) {
const error = new Error(`Invalid input: ${validation.errors.join(', ')}`);
error.code = 'INVALID_INPUT';
error.status = 400;
error.details = validation.errors;
throw error;
}
// Если фронт передал явный generationUuid — берём его (защита от race condition
// при параллельных submitStep-вызовах от одного пользователя в одном сценарии).
// Иначе fallback на самую свежую активную generation (back-compat).
const explicitUuid = input && typeof input.generationUuid === 'string' ? input.generationUuid : null;
let generation;
if (explicitUuid) {
const r = await pool.query(
`SELECT generation_uuid, current_step_id, status FROM uno_bff.generations WHERE user_id = $1 AND scenario_id = $2 AND generation_uuid = $3::uuid LIMIT 1`,
[userId, scenarioId, explicitUuid],
);
generation = r.rows[0];
if (input && 'generationUuid' in input) { delete input.generationUuid; }
} else {
const r = await pool.query(
`SELECT generation_uuid, current_step_id, status FROM uno_bff.generations WHERE user_id = $1 AND scenario_id = $2 AND status IN ('running', 'waiting_for_input') ORDER BY created_at DESC LIMIT 1`,
[userId, scenarioId],
);
generation = r.rows[0];
}
if (!generation) {
const error = new Error(`No active generation found for scenario '${scenarioId}'`);
error.code = 'GENERATION_NOT_FOUND';
error.status = 400;
throw error;
}
// Формируем payload для n8n
const n8nPayload = {
meta: {
userId: user.id,
userEmail: user.email,
scenarioId,
stepId,
generationUuid: generation.generation_uuid,
},
body: input,
};
// Вызов n8n webhook - n8n сам пишет в БД
const n8nUrl = `${env.N8N_BASE_URL}/webhook/scenario/${scenarioId}/step/${stepId}`;
const n8nResponse = await callN8nWebhook(n8nUrl, n8nPayload);
// Возвращаем ответ от n8n
return {
generationUuid: generation.generation_uuid,
stepState: n8nResponse?.stepState || 'processing',
nextStepId: n8nResponse?.nextStepId || stepId,
n8nResponse: n8nResponse?.data || {},
};
}
/**
* Вызов n8n webhook
*/
async function callN8nWebhook(url, payload) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
const error = new Error(`n8n webhook failed: ${response.status} ${response.statusText}`);
error.code = 'N8N_WEBHOOK_ERROR';
error.status = response.status;
error.n8nResponse = errorText;
throw error;
}
const data = await response.json();
return {
runId: data.runId || data.externalRunId || null,
data,
};
} catch (err) {
if (err.code === 'N8N_WEBHOOK_ERROR') {
throw err;
}
const error = new Error(`Failed to call n8n webhook: ${err.message}`);
error.code = 'N8N_CONNECTION_ERROR';
error.status = 503;
throw error;
}
}
@@ -0,0 +1,313 @@
import { env } from '../config/env.js';
import {
getUserScenarioPermission,
getScenarioById,
getScenarioStep,
getFirstScenarioStep,
createGeneration,
updateGeneration,
getGenerationByUuid,
} from '../repositories/scenario.repository.js';
import { pool } from '../db.js';
// Экспорт для использования в routes
export { getScenarioStep };
/**
* Валидация данных по JSON Schema (строгая - все required поля)
*/
function validateInputSchema(inputSchema, data) {
const errors = [];
if (!inputSchema || inputSchema === '{}') {
return { valid: true, errors: [] };
}
const schema = typeof inputSchema === 'string' ? JSON.parse(inputSchema) : inputSchema;
// Проверка required полей
if (schema.required && Array.isArray(schema.required)) {
for (const field of schema.required) {
if (data[field] === undefined || data[field] === null) {
errors.push(`Missing required field: ${field}`);
}
}
}
// Проверка типов полей
if (schema.properties) {
for (const [field, fieldSchema] of Object.entries(schema.properties)) {
const value = data[field];
if (value === undefined || value === null) {
continue; // already checked in required
}
if (fieldSchema.type === 'string' && typeof value !== 'string') {
errors.push(`Field '${field}' must be a string`);
} else if (fieldSchema.type === 'boolean' && typeof value !== 'boolean') {
errors.push(`Field '${field}' must be a boolean`);
} else if (fieldSchema.type === 'number' && typeof value !== 'number') {
errors.push(`Field '${field}' must be a number`);
} else if (fieldSchema.type === 'integer' && (!Number.isInteger(value))) {
errors.push(`Field '${field}' must be an integer`);
} else if (fieldSchema.type === 'array' && !Array.isArray(value)) {
errors.push(`Field '${field}' must be an array`);
} else if (fieldSchema.type === 'object' && (typeof value !== 'object' || Array.isArray(value))) {
errors.push(`Field '${field}' must be an object`);
}
// Проверка minLength для string
if (fieldSchema.type === 'string' && typeof value === 'string' && fieldSchema.minLength !== undefined) {
if (value.length < fieldSchema.minLength) {
errors.push(`Field '${field}' must have at least ${fieldSchema.minLength} characters`);
}
}
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Проверка прав доступа на запуск сценария
*/
export async function assertUserCanStartScenario({ userId, scenarioId }) {
const scenario = await getScenarioById(scenarioId);
if (!scenario) {
const error = new Error(`Scenario '${scenarioId}' not found`);
error.code = 'SCENARIO_NOT_FOUND';
error.status = 404;
throw error;
}
if (scenario.status !== 'active') {
const error = new Error(`Scenario '${scenarioId}' is not active (status: ${scenario.status})`);
error.code = 'SCENARIO_NOT_ACTIVE';
error.status = 403;
throw error;
}
// Публичные сценарии доступны всем авторизованным пользователям
if (!scenario.is_public) {
const permission = await getUserScenarioPermission(userId, scenarioId);
if (!permission || !permission.can_start) {
const error = new Error(`User '${userId}' does not have permission to start scenario '${scenarioId}'`);
error.code = 'SCENARIO_ACCESS_DENIED';
error.status = 403;
throw error;
}
}
return true;
}
/**
* Проверка прав доступа на выполнение шага
*/
export async function assertUserCanExecuteStep({ userId, scenarioId, stepId }) {
const scenario = await getScenarioById(scenarioId);
if (!scenario) {
const error = new Error(`Scenario '${scenarioId}' not found`);
error.code = 'SCENARIO_NOT_FOUND';
error.status = 404;
throw error;
}
const step = await getScenarioStep(scenarioId, stepId);
if (!step) {
const error = new Error(`Step '${stepId}' not found in scenario '${scenarioId}'`);
error.code = 'STEP_NOT_FOUND';
error.status = 404;
throw error;
}
if (step.status !== 'active') {
const error = new Error(`Step '${stepId}' is not active (status: ${step.status})`);
error.code = 'STEP_NOT_ACTIVE';
error.status = 403;
throw error;
}
// Публичные сценарии доступны всем авторизованным пользователям
if (!scenario.is_public) {
const permission = await getUserScenarioPermission(userId, scenarioId);
if (!permission || !permission.can_execute) {
const error = new Error(`User '${userId}' does not have permission to execute steps in scenario '${scenarioId}'`);
error.code = 'SCENARIO_ACCESS_DENIED';
error.status = 403;
throw error;
}
}
return true;
}
/**
* Запуск сценария
*/
export async function startScenario({ userId, scenarioId, input, user }) {
// Валидация input для start - body должен быть пустым или минимальным
// Для start сценария проверяем, что нет лишних данных (body должен быть пустым {})
if (input && Object.keys(input).length > 0) {
const error = new Error('Start scenario body must be empty');
error.code = 'INVALID_START_BODY';
error.status = 400;
throw error;
}
const firstStep = await getFirstScenarioStep(scenarioId);
if (!firstStep) {
const error = new Error(`No active steps found in scenario '${scenarioId}'`);
error.code = 'NO_ACTIVE_STEPS';
error.status = 400;
throw error;
}
// Создаем generation
const generation = await createGeneration({
userId,
scenarioId,
authSessionId: user?.sessionId || null,
requestPayload: {},
currentStepId: firstStep.step_id,
});
// Формируем payload для n8n
// generationUuid НЕ отправляем - n8n должен сгенерировать свой и вернуть в ответе
const n8nPayload = {
meta: {
userId: user.id,
userEmail: user.email,
sessionId: user.sessionId,
scenarioId,
},
body: {},
};
// Вызов n8n webhook
const n8nUrl = `${env.N8N_BASE_URL}/webhook/scenario/${scenarioId}/start`;
const n8nResponse = await callN8nWebhook(n8nUrl, n8nPayload);
// Обновляем generation с external_run_id и generation_uuid от n8n
const updateData = {};
if (n8nResponse?.runId) {
updateData.externalRunId = n8nResponse.runId;
}
if (n8nResponse?.generationUuid) {
// Если n8n вернул свой generationUuid, используем его
// Но это маловероятно - скорее всего мы уже сгенерировали свой
updateData.generationUuid = n8nResponse.generationUuid;
}
if (Object.keys(updateData).length > 0) {
await updateGeneration(generation.generation_uuid, updateData);
}
return {
generationUuid: generation.generation_uuid,
status: generation.status,
currentStepId: firstStep.step_id,
};
}
/**
* Выполнение шага сценария
* BFF только валидирует входные данные и проксирует запрос в n8n.
* n8n отвечает за запись в БД (generation_steps, generations).
*/
export async function executeStep({ userId, scenarioId, stepId, input, user }) {
const step = await getScenarioStep(scenarioId, stepId);
// Валидация input по input_schema из БД
const validation = validateInputSchema(step.input_schema, input);
if (!validation.valid) {
const error = new Error(`Invalid input: ${validation.errors.join(', ')}`);
error.code = 'INVALID_INPUT';
error.status = 400;
error.details = validation.errors;
throw error;
}
// Ищем активную generation для этого пользователя и сценария
const query = `
SELECT generation_uuid, current_step_id, status
FROM uno_bff.generations
WHERE user_id = $1 AND scenario_id = $2 AND status IN ('running', 'waiting_for_input')
ORDER BY created_at DESC
LIMIT 1
`;
const result = await pool.query(query, [userId, scenarioId]);
let generation = result.rows[0];
if (!generation) {
const error = new Error(`No active generation found for scenario '${scenarioId}'`);
error.code = 'GENERATION_NOT_FOUND';
error.status = 400;
throw error;
}
// Формируем payload для n8n
const n8nPayload = {
meta: {
userId: user.id,
userEmail: user.email,
scenarioId,
stepId,
generationUuid: generation.generation_uuid,
},
body: input,
};
// Вызов n8n webhook - n8n сам пишет в БД
const n8nUrl = `${env.N8N_BASE_URL}/webhook/scenario/${scenarioId}/step/${stepId}`;
const n8nResponse = await callN8nWebhook(n8nUrl, n8nPayload);
// Возвращаем ответ от n8n
return {
generationUuid: generation.generation_uuid,
stepState: n8nResponse?.stepState || 'processing',
nextStepId: n8nResponse?.nextStepId || stepId,
n8nResponse: n8nResponse?.data || {},
};
}
/**
* Вызов n8n webhook
*/
async function callN8nWebhook(url, payload) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
const error = new Error(`n8n webhook failed: ${response.status} ${response.statusText}`);
error.code = 'N8N_WEBHOOK_ERROR';
error.status = response.status;
error.n8nResponse = errorText;
throw error;
}
const data = await response.json();
return {
runId: data.runId || data.externalRunId || null,
data,
};
} catch (err) {
if (err.code === 'N8N_WEBHOOK_ERROR') {
throw err;
}
const error = new Error(`Failed to call n8n webhook: ${err.message}`);
error.code = 'N8N_CONNECTION_ERROR';
error.status = 503;
throw error;
}
}
+55
View File
@@ -0,0 +1,55 @@
import crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
const ACCESS_TTL_SEC = Number(process.env.ACCESS_TOKEN_TTL_SEC || 900);
export function hashToken(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
export function generateRefreshToken() {
return crypto.randomBytes(48).toString('base64url');
}
export function generateCsrfToken() {
return crypto.randomBytes(32).toString('base64url');
}
export function signAccessToken(user, sessionId) {
return jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
sid: sessionId,
},
process.env.JWT_SECRET,
{
issuer: process.env.JWT_ISSUER || 'uno-click-bff',
audience: process.env.JWT_AUDIENCE || 'uno-click-web',
expiresIn: ACCESS_TTL_SEC,
}
);
}
export function verifyAccessToken(token) {
return jwt.verify(token, process.env.JWT_SECRET, {
issuer: process.env.JWT_ISSUER || 'uno-click-bff',
audience: process.env.JWT_AUDIENCE || 'uno-click-web',
});
}
export function verifyRefreshToken(token) {
return jwt.verify(token, process.env.JWT_SECRET, {
issuer: process.env.JWT_ISSUER || 'uno-click-bff',
audience: process.env.JWT_AUDIENCE || 'uno-click-web',
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, process.env.JWT_SECRET, {
issuer: process.env.JWT_ISSUER || 'uno-click-bff',
audience: process.env.JWT_AUDIENCE || 'uno-click-web',
expiresIn: '30d',
});
}