nakama/backend/middleware/auth.js

506 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const User = require('../models/User');
const { validateTelegramId } = require('./validator');
const { logSecurityEvent } = require('./logger');
const { validateAndParseInitData } = require('../utils/telegram');
const { fetchLatestAvatar } = require('../jobs/avatarUpdater');
const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot';
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'];
const touchUserActivity = async (user) => {
if (!user) return;
const now = Date.now();
const shouldUpdate =
!user.lastActiveAt ||
Math.abs(now - new Date(user.lastActiveAt).getTime()) > 5 * 60 * 1000;
if (shouldUpdate) {
user.lastActiveAt = new Date(now);
await user.save();
}
};
const ensureUserSettings = async (user) => {
if (!user) return;
let updated = false;
if (!user.settings) {
user.settings = {};
updated = true;
}
if (!ALLOWED_SEARCH_PREFERENCES.includes(user.settings.searchPreference)) {
user.settings.searchPreference = 'furry';
updated = true;
}
if (!user.settings.whitelist) {
user.settings.whitelist = { noNSFW: true, noHomo: true };
updated = true;
} else {
if (user.settings.whitelist.noNSFW === undefined) {
user.settings.whitelist.noNSFW = true;
updated = true;
}
if (user.settings.whitelist.noHomo === undefined) {
user.settings.whitelist.noHomo = true;
updated = true;
}
}
if (updated) {
user.markModified('settings');
await user.save();
}
};
// Нормализовать данные пользователя из Telegram (поддержка camelCase и snake_case)
const normalizeTelegramUser = (telegramUser) => {
return {
id: telegramUser.id,
username: telegramUser.username || telegramUser.userName,
firstName: telegramUser.firstName || telegramUser.first_name || '',
lastName: telegramUser.lastName || telegramUser.last_name || '',
photoUrl: telegramUser.photoUrl || telegramUser.photo_url || null
};
};
// Подтянуть отсутствующие данные пользователя из Telegram
const ensureUserData = async (user, telegramUser) => {
if (!user || !telegramUser) return;
// Нормализовать данные (поддержка camelCase и snake_case)
const normalized = normalizeTelegramUser(telegramUser);
let updated = false;
// Обновить username, если отсутствует или пустой
if (!user.username || user.username.trim() === '') {
if (normalized.username) {
user.username = normalized.username;
updated = true;
} else if (normalized.firstName) {
user.username = normalized.firstName;
updated = true;
}
}
// Обновить firstName, если отсутствует
if (!user.firstName && normalized.firstName) {
user.firstName = normalized.firstName;
updated = true;
}
// Обновить lastName, если отсутствует
if (user.lastName === undefined || user.lastName === null) {
user.lastName = normalized.lastName;
updated = true;
}
// Обновить аватарку, если отсутствует
if (!user.photoUrl) {
// Сначала проверить photoUrl из initData
if (normalized.photoUrl) {
user.photoUrl = normalized.photoUrl;
updated = true;
} else {
// Если нет в initData, попробовать получить через Bot API
try {
const avatarUrl = await fetchLatestAvatar(user.telegramId);
if (avatarUrl) {
user.photoUrl = avatarUrl;
updated = true;
}
} catch (error) {
// Игнорируем ошибки получения аватарки
console.log('Не удалось получить аватарку через Bot API:', error.message);
}
}
}
if (updated) {
await user.save();
}
};
const authenticate = async (req, res, next) => {
try {
const authHeader = req.headers.authorization || '';
let initDataRaw = null;
if (authHeader.startsWith('tma ')) {
initDataRaw = authHeader.slice(4).trim();
}
if (!initDataRaw) {
const headerInitData = req.headers['x-telegram-init-data'];
if (headerInitData && typeof headerInitData === 'string') {
initDataRaw = headerInitData.trim();
}
}
if (!initDataRaw) {
logSecurityEvent('AUTH_TOKEN_MISSING', req);
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
if (!initDataRaw) {
logSecurityEvent('EMPTY_INITDATA', req);
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
let payload;
try {
payload = validateAndParseInitData(initDataRaw);
} catch (error) {
logSecurityEvent('INVALID_INITDATA', req, { reason: error.message });
return res.status(401).json({ error: `${error.message}. ${OFFICIAL_CLIENT_MESSAGE}` });
}
const telegramUser = payload.user;
const startParam = payload.start_param || payload.startParam;
if (!validateTelegramId(telegramUser.id)) {
logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id });
return res.status(401).json({ error: 'Неверный ID пользователя' });
}
// Нормализовать данные пользователя (библиотека возвращает camelCase, но может быть и snake_case)
const normalizedUser = normalizeTelegramUser(telegramUser);
let user = await User.findOne({ telegramId: normalizedUser.id.toString() });
if (!user) {
// Обработка реферального кода из start_param
let referredBy = null;
if (startParam) {
const trimmedParam = startParam.trim();
// Проверяем регистронезависимо (может быть ref_ или REF_)
const normalizedStartParam = trimmedParam.toLowerCase();
if (normalizedStartParam.startsWith('ref_')) {
// Убираем все префиксы ref_/REF_ (может быть двойной префикс ref_REF_)
let codeToSearch = trimmedParam;
while (codeToSearch.toLowerCase().startsWith('ref_')) {
codeToSearch = codeToSearch.substring(4); // Убираем "ref_" или "REF_"
}
// referralCode в базе хранится с префиксом "REF_" (в верхнем регистре)
// Ищем по коду с префиксом REF_
const escapedCode = codeToSearch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const referrer = await User.findOne({
referralCode: { $regex: new RegExp(`^REF_${escapedCode}$`, 'i') }
});
if (referrer) {
referredBy = referrer._id;
console.log(`🔗 Реферальная ссылка: пользователь ${normalizedUser.username || normalizedUser.id} зарегистрирован по ссылке от ${referrer.username} (${referrer._id}), код: ${trimmedParam} -> REF_${codeToSearch}`);
} else {
// Дополнительная проверка: посмотрим все referralCode в базе для отладки
const allCodes = await User.find({ referralCode: { $exists: true } }, { referralCode: 1, username: 1 }).limit(5);
console.warn(`⚠️ Реферальный код не найден: ${trimmedParam} (искали: REF_${codeToSearch})`);
console.warn(` Примеры кодов в базе: ${allCodes.map(u => u.referralCode).join(', ')}`);
}
} else {
console.log(` startParam не содержит ref_: ${trimmedParam}`);
}
}
user = new User({
telegramId: normalizedUser.id.toString(),
username: normalizedUser.username || normalizedUser.firstName || 'user',
firstName: normalizedUser.firstName,
lastName: normalizedUser.lastName,
photoUrl: normalizedUser.photoUrl,
referredBy: referredBy
});
await user.save();
if (referredBy) {
console.log(`✅ Создан новый пользователь ${user.username} (${user._id}) с referredBy: ${referredBy}`);
} else {
console.log(`✅ Создан новый пользователь ${user.username} (${user._id}) без реферала`);
}
// Счетчик рефералов увеличивается только когда пользователь создаст первый пост
// (см. routes/posts.js)
} else {
// Для существующих пользователей тоже можно установить referredBy,
// если они еще не были засчитаны как реферал и пришли по реферальной ссылке
if (startParam && !user.referredBy && !user.referralCounted) {
const trimmedParam = startParam.trim();
const normalizedStartParam = trimmedParam.toLowerCase();
if (normalizedStartParam.startsWith('ref_')) {
// Ищем по полному коду (с префиксом ref_)
const referrer = await User.findOne({
referralCode: { $regex: new RegExp(`^${trimmedParam.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') }
});
if (referrer) {
user.referredBy = referrer._id;
await user.save();
console.log(`🔗 Установлен referredBy для существующего пользователя ${user.username} от ${referrer.username} (код: ${trimmedParam})`);
} else {
// Попробуем альтернативный поиск
const codeWithoutPrefix = trimmedParam.substring(4);
const alternativeSearch = await User.findOne({
$or: [
{ referralCode: { $regex: new RegExp(`^ref_${codeWithoutPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } },
{ referralCode: { $regex: new RegExp(`^REF_${codeWithoutPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } }
]
});
if (alternativeSearch) {
user.referredBy = alternativeSearch._id;
await user.save();
console.log(`🔗 Установлен referredBy (альтернативный поиск) для существующего пользователя ${user.username} от ${alternativeSearch.username}`);
} else {
console.warn(`⚠️ Реферальный код не найден для существующего пользователя ${user.username}: ${trimmedParam}`);
}
}
}
}
// Обновлять только если есть новые данные, не перезаписывать существующие пустыми значениями
if (normalizedUser.username) {
user.username = normalizedUser.username;
} else if (!user.username && normalizedUser.firstName) {
// Если username пустой, использовать firstName как fallback
user.username = normalizedUser.firstName;
}
if (normalizedUser.firstName) {
user.firstName = normalizedUser.firstName;
}
if (normalizedUser.lastName !== undefined) {
user.lastName = normalizedUser.lastName;
}
// Обновлять аватарку только если есть новая
if (normalizedUser.photoUrl) {
user.photoUrl = normalizedUser.photoUrl;
}
await user.save();
}
if (user.banned) {
return res.status(403).json({ error: 'Пользователь заблокирован' });
}
// Подтянуть отсутствующие данные из Telegram (используем нормализованные данные)
await ensureUserData(user, normalizedUser);
await ensureUserSettings(user);
await touchUserActivity(user);
// Реферальная система: отслеживание входов в разные дни
// Останавливаем отслеживание после засчета реферала, чтобы не заполнять БД
if (user.referredBy && !user.referralCounted) {
// Инициализировать loginDates если его нет
if (!user.loginDates) {
user.loginDates = [];
}
const { getMoscowStartOfDay, getMoscowDate } = require('../utils/moscowTime');
const today = getMoscowDate();
const todayNormalized = getMoscowStartOfDay(today);
const todayTime = todayNormalized.getTime();
// Получить уникальные даты из существующего массива (только даты, без времени по московскому времени)
const uniqueDates = new Set();
if (user.loginDates && Array.isArray(user.loginDates)) {
user.loginDates.forEach(date => {
if (!date) return;
try {
// Нормализуем дату: получаем начало дня по московскому времени
const dateObj = new Date(date);
const normalizedDate = getMoscowStartOfDay(dateObj);
const normalizedTime = normalizedDate.getTime();
uniqueDates.add(normalizedTime);
} catch (error) {
console.error(`Ошибка обработки даты ${date}:`, error);
}
});
}
// Добавить сегодняшнюю дату, если её еще нет
const todayExists = uniqueDates.has(todayTime);
if (!todayExists) {
if (!user.loginDates) {
user.loginDates = [];
}
user.loginDates.push(today);
uniqueDates.add(todayTime);
console.log(`📅 Реферал ${user.username} (${user._id}): добавлена дата входа. Уникальных дат: ${uniqueDates.size}/2`);
}
// Если уже есть 2 или более уникальные даты, засчитать реферала
if (uniqueDates.size >= 2) {
const User = require('../models/User');
const referrer = await User.findById(user.referredBy);
if (!referrer) {
console.error(`❌ Реферер не найден для пользователя ${user.username} (${user._id}), referredBy: ${user.referredBy}`);
} else {
// Увеличить счетчик рефералов
const oldCount = referrer.referralsCount || 0;
referrer.referralsCount = oldCount + 1;
await referrer.save();
console.log(`✅ Реферал засчитан: пользователь ${user.username} (${user._id}) засчитан для ${referrer.username} (${referrer._id}). Счетчик: ${oldCount} -> ${referrer.referralsCount}`);
// Начислить баллы за реферала
try {
const { awardReferral } = require('../utils/tickets');
await awardReferral(user.referredBy);
console.log(` ✅ Баллы начислены рефереру ${referrer.username}`);
} catch (error) {
console.error(` ⚠️ Ошибка начисления баллов:`, error.message);
}
}
user.referralCounted = true;
// Очистить loginDates после засчета, чтобы не хранить лишние данные
user.loginDates = [];
await user.save();
} else {
// Если еще нет 2 уникальных дат, сохранить обновленный массив loginDates
await user.save();
}
}
req.user = user;
req.telegramUser = normalizedUser;
next();
} catch (error) {
console.error('❌ Ошибка авторизации:', error);
res.status(401).json({ error: `Ошибка авторизации. ${OFFICIAL_CLIENT_MESSAGE}` });
}
};
// Middleware для проверки роли модератора
const requireModerator = (req, res, next) => {
if (req.user.role !== 'moderator' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Требуются права модератора' });
}
next();
};
// Middleware для проверки роли админа
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Требуются права администратора' });
}
next();
};
// Middleware для модерации (использует MODERATION_BOT_TOKEN)
const authenticateModeration = async (req, res, next) => {
const config = require('../config');
try {
const authHeader = req.headers.authorization || '';
let initDataRaw = null;
if (authHeader.startsWith('tma ')) {
initDataRaw = authHeader.slice(4).trim();
}
if (!initDataRaw) {
const headerInitData = req.headers['x-telegram-init-data'];
if (headerInitData && typeof headerInitData === 'string') {
initDataRaw = headerInitData.trim();
}
}
if (!initDataRaw) {
logSecurityEvent('AUTH_TOKEN_MISSING', req);
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
let payload;
try {
// Use MODERATION_BOT_TOKEN for validation
payload = validateAndParseInitData(initDataRaw, config.moderationBotToken);
} catch (error) {
logSecurityEvent('INVALID_INITDATA', req, { reason: error.message });
return res.status(401).json({ error: `${error.message}. ${OFFICIAL_CLIENT_MESSAGE}` });
}
const telegramUser = payload.user;
if (!validateTelegramId(telegramUser.id)) {
logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id });
return res.status(401).json({ error: 'Неверный ID пользователя' });
}
// Нормализовать данные пользователя (библиотека возвращает camelCase, но может быть и snake_case)
const normalizedUser = normalizeTelegramUser(telegramUser);
let user = await User.findOne({ telegramId: normalizedUser.id.toString() });
if (!user) {
user = new User({
telegramId: normalizedUser.id.toString(),
username: normalizedUser.username || normalizedUser.firstName || 'user',
firstName: normalizedUser.firstName,
lastName: normalizedUser.lastName,
photoUrl: normalizedUser.photoUrl
});
await user.save();
} else {
// Обновлять только если есть новые данные, не перезаписывать существующие пустыми значениями
if (normalizedUser.username) {
user.username = normalizedUser.username;
} else if (!user.username && normalizedUser.firstName) {
// Если username пустой, использовать firstName как fallback
user.username = normalizedUser.firstName;
}
if (normalizedUser.firstName) {
user.firstName = normalizedUser.firstName;
}
if (normalizedUser.lastName !== undefined) {
user.lastName = normalizedUser.lastName;
}
// Обновлять аватарку только если есть новая
if (normalizedUser.photoUrl) {
user.photoUrl = normalizedUser.photoUrl;
}
await user.save();
}
if (user.banned) {
return res.status(403).json({ error: 'Пользователь заблокирован' });
}
// Подтянуть отсутствующие данные из Telegram (используем нормализованные данные)
await ensureUserData(user, normalizedUser);
await ensureUserSettings(user);
await touchUserActivity(user);
req.user = user;
req.telegramUser = normalizedUser;
next();
} catch (error) {
console.error('❌ Ошибка авторизации модерации:', error);
res.status(401).json({ error: `Ошибка авторизации. ${OFFICIAL_CLIENT_MESSAGE}` });
}
};
module.exports = {
authenticate,
authenticateModeration,
requireModerator,
requireAdmin,
touchUserActivity,
ensureUserSettings,
ensureUserData
};