- validateTelemostRoom: таймаут 3 сек + кэш 10 мин - createVpnTask: транзакция + batch insert field values - prepareVpnSubscription: валидация и создание подписки в фоне при получении ссылки - sendVpnBotMessage: кэш botId, Promise.all для enrich/members/orgMap - processPlatformSelection: одно сообщение с файлом и подпиской вместо двух - Chat.tsx: нажатая кнопка становится зелёной галочкой, остальные disabled
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
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;
|
||
}
|
||
}
|