initial commit
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user