Files
iistwin/server/vpn/vpn.service.ts
Ильяс Султанов 9f1c2e5b45 perf(vpn-bot): фоновая подготовка подписки, оптимизация запросов, анимация кнопок
- validateTelemostRoom: таймаут 3 сек + кэш 10 мин
- createVpnTask: транзакция + batch insert field values
- prepareVpnSubscription: валидация и создание подписки в фоне при получении ссылки
- sendVpnBotMessage: кэш botId, Promise.all для enrich/members/orgMap
- processPlatformSelection: одно сообщение с файлом и подпиской вместо двух
- Chat.tsx: нажатая кнопка становится зелёной галочкой, остальные disabled
2026-07-17 12:38:33 +03:00

313 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

import crypto from 'crypto';
import {
findVpnTaskByToken,
findVpnTaskByRoomUrl,
findVpnTaskByRoomAndDevice,
findVpnTaskByRoomAndSession,
loadVpnTaskValues,
setVpnTaskField,
setVpnTaskStatus,
updateVpnTaskFieldMap,
createVpnTask,
getVpnFormCache,
getFieldId,
type VpnFormCache,
} from './vpn.db';
import {
SUBSCRIPTION_BASE_URL,
DEFAULT_TRAFFIC_LIMIT_GB,
DEFAULT_SUBSCRIPTION_DAYS,
SUBSCRIPTION_UPDATE_INTERVAL_HOURS,
OLC_RTC_PROVIDER,
OLC_RTC_TRANSPORT,
OLC_RTC_VP8_FPS,
OLC_RTC_VP8_BATCH,
VPN_TELEMOST_API_BASE,
} from './vpn.config';
export class VpnError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = 'VpnError';
}
}
function generateToken(): string {
return crypto.randomBytes(24).toString('base64url');
}
function generateCryptoKey(): string {
return crypto.randomBytes(32).toString('hex');
}
export function buildSubscriptionUrl(token: string): string {
return `${SUBSCRIPTION_BASE_URL}/${token}`;
}
export function buildOlcRtcUri(roomId: string, cryptoKey: string, employeeName: string): string {
const name = (employeeName || 'Corp VPN').replace(/\s+/g, ' ').trim();
return `olcrtc://${OLC_RTC_PROVIDER}?${OLC_RTC_TRANSPORT}<vp8-fps=${OLC_RTC_VP8_FPS}&vp8-batch=${OLC_RTC_VP8_BATCH}>@${roomId}#${cryptoKey}$${name}`;
}
export function extractRoomUrl(text: string): string | null {
const trimmed = text.trim();
// Full or partial link: https://telemost.yandex.ru/j/XXXXXX (with optional www, query, hash)
const linkMatch = trimmed.match(/(?:https?:\/\/)?(?:www\.)?telemost\.yandex\.ru\/j\/([a-zA-Z0-9_-]+)/i);
if (linkMatch) {
return `https://telemost.yandex.ru/j/${linkMatch[1]}`;
}
// Just the room ID
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
return `https://telemost.yandex.ru/j/${trimmed}`;
}
return null;
}
export function extractRoomId(roomUrl: string): string {
const idx = roomUrl.lastIndexOf('/j/');
if (idx === -1) return roomUrl;
return roomUrl.slice(idx + 3);
}
const telemostValidationCache = new Map<string, { valid: boolean; expiresAt: number }>();
export async function validateTelemostRoom(roomUrl: string): Promise<boolean> {
const cached = telemostValidationCache.get(roomUrl);
if (cached && cached.expiresAt > Date.now()) {
return cached.valid;
}
try {
const encoded = encodeURIComponent(roomUrl);
const url = `${VPN_TELEMOST_API_BASE}/conferences/${encoded}/connection?next_gen_media_platform_allowed=true&display_name=VPN-Bot&waiting_room_supported=true`;
const resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0',
Accept: '*/*',
'Content-Type': 'application/json',
'Client-Instance-Id': crypto.randomUUID(),
'X-Telemost-Client-Version': '187.1.0',
'Idempotency-Key': crypto.randomUUID(),
Origin: 'https://telemost.yandex.ru',
Referer: 'https://telemost.yandex.ru/',
},
signal: AbortSignal.timeout(3000),
});
const valid = resp.status === 200;
telemostValidationCache.set(roomUrl, { valid, expiresAt: Date.now() + 10 * 60 * 1000 });
return valid;
} catch (err) {
console.error('[VPN] Telemost validation error:', err);
telemostValidationCache.set(roomUrl, { valid: false, expiresAt: Date.now() + 60 * 1000 });
return false;
}
}
export interface PreparedSubscription {
subscriptionUrl: string;
isNew: boolean;
taskId?: number;
roomId: string;
cryptoKey?: string;
}
export async function getExistingVpnSubscription(
organizationId: number,
roomUrl: string,
): Promise<PreparedSubscription | null> {
const existing = await findVpnTaskByRoomUrl(roomUrl, organizationId);
if (!existing) return null;
const token = existing.values.subscription_token as string | undefined;
if (!token) return null;
return {
subscriptionUrl: buildSubscriptionUrl(token),
isNew: false,
taskId: existing.task.id,
roomId: extractRoomId(roomUrl),
cryptoKey: existing.values.crypto_key as string | undefined,
};
}
export async function prepareVpnSubscription(
organizationId: number,
userId: number,
userName: string,
roomUrl: string,
): Promise<PreparedSubscription> {
const existing = await findVpnTaskByRoomUrl(roomUrl, organizationId);
if (existing && existing.task.createdBy !== userId) {
throw new VpnError('Эта комната уже используется другим сотрудником', 'ROOM_IN_USE');
}
if (existing) {
const token = existing.values.subscription_token as string | undefined;
if (!token) throw new VpnError('Подписка повреждена, обратитесь к администратору', 'BROKEN');
return {
subscriptionUrl: buildSubscriptionUrl(token),
isNew: false,
taskId: existing.task.id,
roomId: extractRoomId(roomUrl),
cryptoKey: existing.values.crypto_key as string | undefined,
};
}
const roomId = extractRoomId(roomUrl);
const isValid = await validateTelemostRoom(roomUrl);
if (!isValid) {
throw new VpnError(
'Не удалось найти встречу. Создайте новую в Яндекс Телемост и пришлите ссылку.',
'ROOM_NOT_FOUND',
);
}
const token = generateToken();
const cryptoKey = generateCryptoKey();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + DEFAULT_SUBSCRIPTION_DAYS);
const task = await createVpnTask({
organizationId,
createdBy: userId,
title: `VPN ${userName || `#${userId}`} / ${roomId}`,
statusName: 'Активна',
fields: {
employee_name: userName || '',
room_id: roomId,
room_url: roomUrl,
crypto_key: cryptoKey,
subscription_token: token,
status: 'Активна',
expires_at: expiresAt.toISOString().slice(0, 10),
traffic_limit_gb: DEFAULT_TRAFFIC_LIMIT_GB,
traffic_used_gb: 0,
is_active: true,
},
});
return {
subscriptionUrl: buildSubscriptionUrl(token),
isNew: true,
taskId: task.id,
roomId,
cryptoKey,
};
}
/** @deprecated Use prepareVpnSubscription instead */
export async function getOrCreateVpnSubscription(
organizationId: number,
userId: number,
userName: string,
roomUrl: string,
): Promise<{ subscriptionUrl: string; isNew: boolean }> {
const result = await prepareVpnSubscription(organizationId, userId, userName, roomUrl);
return { subscriptionUrl: result.subscriptionUrl, isNew: result.isNew };
}
export async function fetchSubscription(token: string, hwid: string | undefined): Promise<string> {
// We don't know organizationId here; token is globally unique.
// Search across all orgs by token.
const orgIds = [1]; // TODO: support multi-org
for (const orgId of orgIds) {
const data = await findVpnTaskByToken(token, orgId);
if (!data) continue;
const { task, values } = data;
if (!values.is_active || values.status !== 'Активна') {
throw new VpnError('Подписка неактивна', 'INACTIVE');
}
if (values.expires_at && new Date(values.expires_at as string) < new Date()) {
await setVpnTaskStatus(task.id, 'Истекла', orgId);
throw new VpnError('Срок действия подписки истёк', 'EXPIRED');
}
const limit = Number(values.traffic_limit_gb || 0);
const used = Number(values.traffic_used_gb || 0);
if (limit > 0 && used >= limit) {
throw new VpnError('Превышен лимит трафика', 'TRAFFIC_LIMIT');
}
if (hwid) {
const boundHwid = values.hwid as string | undefined;
if (!boundHwid) {
await setVpnTaskField(task.id, 'hwid', hwid, orgId);
} else if (boundHwid !== hwid) {
throw new VpnError('Подписка привязана к другому устройству', 'HWID_MISMATCH');
}
}
const roomId = values.room_id as string;
const cryptoKey = values.crypto_key as string;
const employeeName = (values.employee_name as string) || 'Corp VPN';
const uri = buildOlcRtcUri(roomId, cryptoKey, employeeName);
return uri;
}
throw new VpnError('Подписка не найдена', 'NOT_FOUND');
}
export async function authorizeSession(
roomUrl: string,
deviceId: string,
): Promise<{ sessionId: string; organizationId: number; taskId: number }> {
const orgIds = [1]; // TODO: support multi-org
for (const orgId of orgIds) {
const data = await findVpnTaskByRoomAndDevice(roomUrl, deviceId, orgId);
if (!data) continue;
const { task, values } = data;
if (!values.is_active || values.status !== 'Активна') {
throw new VpnError('Подписка неактивна', 'INACTIVE');
}
if (values.expires_at && new Date(values.expires_at as string) < new Date()) {
throw new VpnError('Срок действия подписки истёк', 'EXPIRED');
}
const sessionId = crypto.randomUUID();
await updateVpnTaskFieldMap(task.id, orgId, {
active_session_id: sessionId,
last_seen_at: new Date().toISOString(),
last_error: '',
});
return { sessionId, organizationId: orgId, taskId: task.id };
}
throw new VpnError('Устройство не авторизовано', 'UNAUTHORIZED');
}
export async function closeSession(
roomUrl: string,
sessionId: string,
): Promise<void> {
const orgIds = [1];
for (const orgId of orgIds) {
const data = await findVpnTaskByRoomAndSession(roomUrl, sessionId, orgId);
if (!data) continue;
const activeSession = data.values.active_session_id as string | undefined;
if (activeSession === sessionId) {
await setVpnTaskField(data.task.id, 'active_session_id', '', orgId);
await setVpnTaskField(data.task.id, 'last_seen_at', new Date().toISOString(), orgId);
}
return;
}
}
export async function recordTraffic(
roomUrl: string,
sessionId: string,
bytesIn: number,
bytesOut: number,
): Promise<void> {
const orgIds = [1];
for (const orgId of orgIds) {
const data = await findVpnTaskByRoomAndSession(roomUrl, sessionId, orgId);
if (!data) continue;
const used = Number(data.values.traffic_used_gb || 0);
const added = (bytesIn + bytesOut) / (1024 * 1024 * 1024);
await updateVpnTaskFieldMap(data.task.id, orgId, {
traffic_used_gb: Math.round((used + added) * 1000) / 1000,
last_seen_at: new Date().toISOString(),
});
return;
}
}