diff --git a/src/max-mini-app.js b/src/max-mini-app.js new file mode 100644 index 0000000..02a4c69 --- /dev/null +++ b/src/max-mini-app.js @@ -0,0 +1,115 @@ +import crypto from 'node:crypto'; + +const MAX_MINI_APP_AUTH_MAX_AGE_SECONDS = Number(process.env.MAX_MINI_APP_AUTH_MAX_AGE_SECONDS ?? 60 * 60); + +function requireMaxBotToken() { + const token = String(process.env.MAX_BOT_TOKEN || '').trim(); + if (!token) { + throw new Error('MAX_BOT_TOKEN is not configured.'); + } + return token; +} + +function timingSafeEqualHex(left, right) { + const leftBuffer = Buffer.from(String(left || ''), 'hex'); + const rightBuffer = Buffer.from(String(right || ''), 'hex'); + + if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function parseJsonParam(rawValue, fieldName) { + if (!rawValue) { + return null; + } + + let parsed; + try { + parsed = JSON.parse(rawValue); + } catch { + throw new Error(`MAX ${fieldName} is invalid.`); + } + + if (!parsed || typeof parsed !== 'object') { + throw new Error(`MAX ${fieldName} is invalid.`); + } + + return parsed; +} + +function parseMaxUser(rawUser) { + const parsed = parseJsonParam(rawUser, 'user'); + if (!parsed?.id) { + throw new Error('MAX user is missing in initData.'); + } + + return { + id: String(parsed.id), + first_name: String(parsed.first_name || '').trim(), + last_name: String(parsed.last_name || '').trim() || null, + username: String(parsed.username || '').trim() || null, + language_code: String(parsed.language_code || '').trim() || null, + photo_url: String(parsed.photo_url || '').trim() || null, + }; +} + +function parseMaxChat(rawChat) { + const parsed = parseJsonParam(rawChat, 'chat'); + if (!parsed?.id) { + return null; + } + + return { + id: String(parsed.id), + type: String(parsed.type || '').trim() || null, + }; +} + +export function validateMaxMiniAppInitData(initDataRaw) { + const initData = String(initDataRaw || '').trim(); + if (!initData) { + throw new Error('MAX initData is required.'); + } + + const params = new URLSearchParams(initData); + const receivedHashes = params.getAll('hash').map((value) => String(value || '').trim().toLowerCase()).filter(Boolean); + if (receivedHashes.length !== 1) { + throw new Error('MAX initData hash is missing.'); + } + + const authDate = Number(params.get('auth_date')); + if (!Number.isFinite(authDate) || authDate <= 0) { + throw new Error('MAX initData auth_date is invalid.'); + } + + const authDateSeconds = authDate > 1e12 ? Math.floor(authDate / 1000) : Math.floor(authDate); + + const now = Math.floor(Date.now() / 1000); + if (now - authDateSeconds > MAX_MINI_APP_AUTH_MAX_AGE_SECONDS) { + throw new Error('MAX initData is expired.'); + } + + const dataCheckString = [...params.entries()] + .filter(([key]) => key !== 'hash') + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + + const secretKey = crypto.createHmac('sha256', 'WebAppData').update(requireMaxBotToken()).digest(); + const expectedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); + + if (!timingSafeEqualHex(expectedHash, receivedHashes[0])) { + throw new Error('MAX initData signature is invalid.'); + } + + return { + authDate: new Date(authDateSeconds * 1000), + queryId: String(params.get('query_id') || '').trim() || null, + startParam: String(params.get('start_param') || '').trim() || null, + user: parseMaxUser(params.get('user')), + chat: parseMaxChat(params.get('chat')), + }; +} diff --git a/src/messenger-connections.js b/src/messenger-connections.js index bfb1520..b24203b 100644 --- a/src/messenger-connections.js +++ b/src/messenger-connections.js @@ -3,7 +3,7 @@ function normalizeOptionalText(value) { return normalized || null; } -export function normalizeTelegramProfile(profile) { +function normalizeMessengerProfile(profile) { if (!profile || typeof profile !== 'object') { return { displayName: null, @@ -21,6 +21,17 @@ export function normalizeTelegramProfile(profile) { }; } +export function normalizeTelegramProfile(profile) { + return normalizeMessengerProfile(profile); +} + +export function normalizeMaxProfile(profile) { + return normalizeMessengerProfile({ + displayName: profile?.displayName, + username: profile?.username, + }); +} + export function profileFromTelegramMiniAppUser(user) { if (!user || typeof user !== 'object') { return normalizeTelegramProfile(null); @@ -36,6 +47,21 @@ export function profileFromTelegramMiniAppUser(user) { }); } +export function profileFromMaxMiniAppUser(user) { + if (!user || typeof user !== 'object') { + return normalizeMaxProfile(null); + } + + const firstName = String(user.first_name || '').trim(); + const lastName = String(user.last_name || '').trim(); + const displayName = `${firstName} ${lastName}`.trim(); + + return normalizeMaxProfile({ + displayName, + username: user.username, + }); +} + export async function upsertActiveMessengerConnection(prisma, { userId, type, channelId, profile = null }) { const normalizedChannelId = String(channelId || '').trim(); if (!userId || !type || !normalizedChannelId) { @@ -44,7 +70,9 @@ export async function upsertActiveMessengerConnection(prisma, { userId, type, ch const normalizedProfile = type === 'TELEGRAM' ? normalizeTelegramProfile(profile) - : normalizeTelegramProfile(null); + : type === 'MAX' + ? normalizeMaxProfile(profile) + : normalizeMessengerProfile(null); return prisma.$transaction(async (tx) => { await tx.messengerConnection.updateMany({ diff --git a/src/server.js b/src/server.js index 78b87db..45a347e 100644 --- a/src/server.js +++ b/src/server.js @@ -21,7 +21,14 @@ import { } from './auth.js'; import { canManagerAccessUser, isManagerRole } from './access.js'; import { buildContext } from './context.js'; -import { profileFromTelegramMiniAppUser, normalizeTelegramProfile, upsertActiveMessengerConnection } from './messenger-connections.js'; +import { + normalizeMaxProfile, + normalizeTelegramProfile, + profileFromMaxMiniAppUser, + profileFromTelegramMiniAppUser, + upsertActiveMessengerConnection, +} from './messenger-connections.js'; +import { validateMaxMiniAppInitData } from './max-mini-app.js'; import { sendMessengerMessage } from './messenger.js'; import { prisma } from './prisma-client.js'; import { resolvers } from './resolvers.js'; @@ -96,6 +103,14 @@ function normalizeRedirectPath(value) { } function presentTelegramMiniAppUser(user) { + return presentMiniAppUser(user, 'Пользователь Telegram'); +} + +function presentMaxMiniAppUser(user) { + return presentMiniAppUser(user, 'Пользователь MAX'); +} + +function presentMiniAppUser(user, fallbackDisplayName) { const firstName = String(user?.first_name || '').trim(); const lastName = String(user?.last_name || '').trim(); @@ -106,7 +121,7 @@ function presentTelegramMiniAppUser(user) { username: String(user?.username || '').trim() || null, languageCode: String(user?.language_code || '').trim() || null, photoUrl: String(user?.photo_url || '').trim() || null, - displayName: `${firstName} ${lastName}`.trim() || firstName || 'Пользователь Telegram', + displayName: `${firstName} ${lastName}`.trim() || firstName || fallbackDisplayName, }; } @@ -252,6 +267,93 @@ app.post('/auth/telegram-mini-app/connect', async (req, res) => { }); }); +app.post('/auth/max-mini-app/session', async (req, res) => { + let max; + try { + max = validateMaxMiniAppInitData(req.body?.initData); + } catch (error) { + res.status(401).json({ error: error.message }); + return; + } + + const channelId = String(max.user.id); + const connection = await prisma.messengerConnection.findFirst({ + where: { + type: 'MAX', + channelId, + isActive: true, + }, + include: { + user: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (!connection?.user) { + res.json({ + ok: true, + authenticated: false, + maxUser: presentMaxMiniAppUser(max.user), + }); + return; + } + + await upsertActiveMessengerConnection(prisma, { + userId: connection.user.id, + type: 'MAX', + channelId, + profile: profileFromMaxMiniAppUser(max.user), + }); + + const session = issueAccessToken(connection.user.id); + res.json({ + ok: true, + authenticated: true, + accessToken: session.accessToken, + expiresAt: session.expiresAt.toISOString(), + user: presentAuthUser(connection.user), + maxUser: presentMaxMiniAppUser(max.user), + }); +}); + +app.post('/auth/max-mini-app/connect', async (req, res) => { + const user = await resolveAuthenticatedUserFromRequest(req); + if (!user) { + res.status(401).json({ error: 'Authentication required.' }); + return; + } + + let max; + try { + max = validateMaxMiniAppInitData(req.body?.initData); + } catch (error) { + res.status(401).json({ error: error.message }); + return; + } + + const connection = await upsertActiveMessengerConnection(prisma, { + userId: user.id, + type: 'MAX', + channelId: max.user.id, + profile: profileFromMaxMiniAppUser(max.user), + }); + + res.json({ + ok: true, + connection: { + id: connection.id, + userId: connection.userId, + type: connection.type, + channelId: connection.channelId, + displayName: connection.displayName, + username: connection.username, + avatarAvailable: Boolean(connection.avatarFileId), + isActive: connection.isActive, + }, + maxUser: presentMaxMiniAppUser(max.user), + }); +}); + app.post('/bot/messenger-login', async (req, res) => { const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN; const providedToken = req.body?.token; @@ -301,7 +403,9 @@ app.post('/bot/messenger-login', async (req, res) => { messengerConnection: { type: channel, channelId, - ...normalizeTelegramProfile(req.body?.profile), + ...(channel === 'TELEGRAM' + ? normalizeTelegramProfile(req.body?.profile) + : normalizeMaxProfile(req.body?.profile)), }, }); const frontendUrl = ( @@ -310,7 +414,7 @@ app.post('/bot/messenger-login', async (req, res) => { 'http://localhost:3000' ).replace(/\/$/, ''); const nextPath = startSession.redirectPath || ( - channel === 'TELEGRAM' + channel === 'TELEGRAM' || channel === 'MAX' ? `/profile/notifications/success?connected=${channel.toLowerCase()}` : '' );