fix(vpn): восстановление VPN-модуля после затирания chat/push fix'ом
- Добавлена ветка bot.type === 'vpn' в messenger.messages.routes.ts - Добавлен полный модуль server/vpn/ (routes, service, db, bot, config) - Подключены VPN-маршруты в server/routes/index.ts - Добавлена раздача /apps статики и COPY в Dockerfile - apps/ пока содержит .gitkeep и README; APK/IPA нужно добавить отдельно
This commit is contained in:
@@ -30,6 +30,9 @@ COPY --from=builder /app/server/scripts ./server/scripts
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# VPN client apps (olcbox APK/IPA)
|
||||
COPY --from=builder /app/apps /app/apps
|
||||
|
||||
# Python + python-docx для обработки DOCX-шаблонов
|
||||
RUN apk add --no-cache python3 py3-pip py3-lxml \
|
||||
&& pip3 install --break-system-packages python-docx \
|
||||
|
||||
0
apps/.gitkeep
Normal file
0
apps/.gitkeep
Normal file
10
apps/README.md
Normal file
10
apps/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# VPN client apps
|
||||
|
||||
Эта папка должна содержать файлы приложений olcbox, которые раздаёт VPN-бот:
|
||||
|
||||
- `Olcbox-1.0.112-android-release.apk`
|
||||
- `Olcbox-1.0.112-ios-unsigned.ipa`
|
||||
|
||||
Файлы копируются в образ через `COPY --from=builder /app/apps /app/apps` в `Dockerfile`.
|
||||
|
||||
Без этих файлов сборка пройдёт, но бот не сможет прикрепить APK/IPA к сообщениям.
|
||||
@@ -986,6 +986,9 @@ async function runStartupDataPatches() {
|
||||
|
||||
const server = await registerRoutes(app);
|
||||
|
||||
// Static files for VPN client apps (olcbox APK/IPA)
|
||||
app.use('/apps', express.static('/app/apps'));
|
||||
|
||||
// Seed application roles and permissions
|
||||
try {
|
||||
await storage.seedAppRolesAndPermissions();
|
||||
|
||||
@@ -40,6 +40,7 @@ import organizationSkinsRouter from "./organization-skins.routes";
|
||||
import { registerSyncRoutes } from "./sync.routes";
|
||||
import { registerMedScheduleRoutes } from "../medschedule/routes";
|
||||
import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes";
|
||||
import { registerVpnRoutes } from "../vpn";
|
||||
import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types";
|
||||
export { publishNotificationSSE };
|
||||
|
||||
@@ -117,6 +118,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
registerSyncRoutes(app);
|
||||
registerMedScheduleRoutes(app);
|
||||
registerMedScheduleBotRoutes(app);
|
||||
registerVpnRoutes(app);
|
||||
|
||||
const httpServer = createServer(app);
|
||||
return httpServer;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatUserName } from "../utils/formatUserName";
|
||||
import { notificationService } from "../services/notification.service";
|
||||
import { handleAiBotDirectMessage } from "../services/ai-bot.service";
|
||||
import { checkBotAccess } from "../services/ai-bot.service";
|
||||
import { handleVpnBotDirectMessage } from "../vpn/vpn-bot.service";
|
||||
|
||||
export function registerMessengerMessageRoutes(router: Router): void {
|
||||
// GET /api/messenger/conversations/:id/messages
|
||||
@@ -249,71 +250,111 @@ export function registerMessengerMessageRoutes(router: Router): void {
|
||||
.where(eq(bots.id, botId));
|
||||
|
||||
if (!bot || !bot.isActive) return;
|
||||
if (bot.type !== 'ai_assistant') return;
|
||||
const typedBot = bot as Bot;
|
||||
if (!checkBotAccess(typedBot, req.user!)) {
|
||||
const [denyMsg] = await db
|
||||
.insert(conversationMessages)
|
||||
.values({
|
||||
|
||||
if (bot.type === 'ai_assistant') {
|
||||
const typedBot = bot as Bot;
|
||||
if (!checkBotAccess(typedBot, req.user!)) {
|
||||
const [denyMsg] = await db
|
||||
.insert(conversationMessages)
|
||||
.values({
|
||||
conversationId: convId,
|
||||
authorId: null,
|
||||
botId: bot.id,
|
||||
message: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||
replyToId: null,
|
||||
mentionedUserIds: null,
|
||||
attachments: null,
|
||||
})
|
||||
.returning();
|
||||
const enrichedDeny = await enrichMessage(denyMsg.id);
|
||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
|
||||
}
|
||||
notificationService.notifyMessengerMessage({
|
||||
conversationId: convId,
|
||||
authorId: null,
|
||||
botId: bot.id,
|
||||
message: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||
replyToId: null,
|
||||
mentionedUserIds: null,
|
||||
attachments: null,
|
||||
})
|
||||
.returning();
|
||||
const enrichedDeny = await enrichMessage(denyMsg.id);
|
||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
|
||||
authorId: -1,
|
||||
authorName: bot.name,
|
||||
chatName: bot.name,
|
||||
chatType: 'bot_direct',
|
||||
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||
mentionedUserIds: [],
|
||||
memberOrgMap,
|
||||
}).catch(err => console.error('[Bot Deny Notify] Error:', err));
|
||||
return;
|
||||
}
|
||||
notificationService.notifyMessengerMessage({
|
||||
|
||||
await handleAiBotDirectMessage({
|
||||
bot: typedBot,
|
||||
conversationId: convId,
|
||||
authorId: -1,
|
||||
authorName: bot.name,
|
||||
chatName: bot.name,
|
||||
chatType: 'bot_direct',
|
||||
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||
mentionedUserIds: [],
|
||||
memberOrgMap,
|
||||
}).catch(err => console.error('[Bot Deny Notify] Error:', err));
|
||||
return;
|
||||
}
|
||||
message: message.trim(),
|
||||
user: req.user!,
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
await handleAiBotDirectMessage({
|
||||
bot: typedBot,
|
||||
conversationId: convId,
|
||||
message: message.trim(),
|
||||
user: req.user!,
|
||||
organizationId: orgId,
|
||||
});
|
||||
const latestBotMsg = await db
|
||||
.select({ id: conversationMessages.id })
|
||||
.from(conversationMessages)
|
||||
.where(and(
|
||||
eq(conversationMessages.conversationId, convId),
|
||||
eq(conversationMessages.botId, botId),
|
||||
))
|
||||
.orderBy(desc(conversationMessages.id))
|
||||
.limit(1);
|
||||
|
||||
const latestBotMsg = await db
|
||||
.select({ id: conversationMessages.id })
|
||||
.from(conversationMessages)
|
||||
.where(and(
|
||||
eq(conversationMessages.conversationId, convId),
|
||||
eq(conversationMessages.botId, botId),
|
||||
))
|
||||
.orderBy(desc(conversationMessages.id))
|
||||
.limit(1);
|
||||
|
||||
if (latestBotMsg[0]) {
|
||||
const enrichedBot = await enrichMessage(latestBotMsg[0].id);
|
||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId });
|
||||
if (latestBotMsg[0]) {
|
||||
const enrichedBot = await enrichMessage(latestBotMsg[0].id);
|
||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId });
|
||||
}
|
||||
notificationService.notifyMessengerMessage({
|
||||
conversationId: convId,
|
||||
authorId: -1,
|
||||
authorName: bot.name,
|
||||
chatName: bot.name,
|
||||
chatType: 'bot_direct',
|
||||
messageText: (enrichedBot as any).message ?? '',
|
||||
mentionedUserIds: [],
|
||||
memberOrgMap,
|
||||
}).catch(err => console.error('[Bot Reply Notify] Error:', err));
|
||||
}
|
||||
notificationService.notifyMessengerMessage({
|
||||
} else if (bot.type === 'vpn') {
|
||||
const typedBot = bot as Bot;
|
||||
if (!checkBotAccess(typedBot, req.user!)) {
|
||||
const [denyMsg] = await db
|
||||
.insert(conversationMessages)
|
||||
.values({
|
||||
conversationId: convId,
|
||||
authorId: null,
|
||||
botId: bot.id,
|
||||
message: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||
replyToId: null,
|
||||
mentionedUserIds: null,
|
||||
attachments: null,
|
||||
})
|
||||
.returning();
|
||||
const enrichedDeny = await enrichMessage(denyMsg.id);
|
||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
|
||||
}
|
||||
notificationService.notifyMessengerMessage({
|
||||
conversationId: convId,
|
||||
authorId: -1,
|
||||
authorName: bot.name,
|
||||
chatName: bot.name,
|
||||
chatType: 'bot_direct',
|
||||
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||
mentionedUserIds: [],
|
||||
memberOrgMap,
|
||||
}).catch(err => console.error('[VPN Bot Deny Notify] Error:', err));
|
||||
return;
|
||||
}
|
||||
await handleVpnBotDirectMessage({
|
||||
bot: typedBot,
|
||||
conversationId: convId,
|
||||
authorId: -1,
|
||||
authorName: bot.name,
|
||||
chatName: bot.name,
|
||||
chatType: 'bot_direct',
|
||||
messageText: (enrichedBot as any).message ?? '',
|
||||
mentionedUserIds: [],
|
||||
memberOrgMap,
|
||||
}).catch(err => console.error('[Bot Reply Notify] Error:', err));
|
||||
message: message.trim(),
|
||||
user: req.user!,
|
||||
organizationId: orgId,
|
||||
});
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
|
||||
1
server/vpn/index.ts
Normal file
1
server/vpn/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { registerVpnRoutes } from './vpn.routes';
|
||||
222
server/vpn/vpn-bot.service.ts
Normal file
222
server/vpn/vpn-bot.service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { db } from "../db";
|
||||
import { conversationMessages, conversationMembers, users, bots, type Bot, type User } from "@shared/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eventBus } from "../routes/shared";
|
||||
import { enrichMessage, getMembersForSSE } from "../routes/messenger.helpers";
|
||||
import { notificationService } from "../services/notification.service";
|
||||
import { getOrCreateVpnSubscription, extractRoomUrl } from "./vpn.service";
|
||||
import { VPN_BOT_LOGIN } from "./vpn.config";
|
||||
|
||||
const APPS_BASE_URL = process.env.APP_URL
|
||||
? `${process.env.APP_URL.replace(/\/$/, "")}/apps`
|
||||
: "https://iistwin.ru/apps";
|
||||
|
||||
const ANDROID_FILE = "Olcbox-1.0.112-android-release.apk";
|
||||
const IOS_FILE = "Olcbox-1.0.112-ios-unsigned.ipa";
|
||||
|
||||
const INSTRUCTIONS_TEXT = `Привет! Для получения корпоративного VPN:
|
||||
|
||||
1. Открой Яндекс Телемост (приложение или https://telemost.yandex.ru).
|
||||
2. Создай новую видеовстречу.
|
||||
3. Пришли сюда ссылку на встречу. Примеры того, что подходит:
|
||||
|
||||
• https://telemost.yandex.ru/j/abc123def456
|
||||
• telemost.yandex.ru/j/abc123def456
|
||||
• abc123def456
|
||||
|
||||
Можно прислать и сопроводительный текст — я сам найду ссылку.`;
|
||||
|
||||
const PLATFORM_PROMPT = `Ссылка принята. Теперь скажи, какой у тебя телефон:\n\n• Android\n• iPhone (iOS)\n\nЯ пришлю подходящий файл приложения olcbox.`;
|
||||
|
||||
const IOS_WARNING = `⚠️ Файл для iOS не подписан. Как его установить на iPhone — честно, не знаю, придётся разбираться самому (AltStore, enterprise-сертификат и т.п.).`;
|
||||
|
||||
function formatUserName(user: User): string {
|
||||
const parts = [user.firstName, user.lastName].filter(Boolean);
|
||||
return parts.join(" ") || user.email || "Сотрудник";
|
||||
}
|
||||
|
||||
type Attachment = { url: string; name: string; size: number; mimeType?: string };
|
||||
|
||||
export async function sendVpnBotMessage(
|
||||
conversationId: number,
|
||||
text: string,
|
||||
organizationId: number,
|
||||
attachments?: Attachment[],
|
||||
): Promise<void> {
|
||||
const [msg] = await db
|
||||
.insert(conversationMessages)
|
||||
.values({
|
||||
conversationId,
|
||||
authorId: null,
|
||||
botId: (await getVpnBotId(organizationId)) ?? null,
|
||||
message: text.trim(),
|
||||
replyToId: null,
|
||||
mentionedUserIds: null,
|
||||
attachments: attachments?.length ? attachments : null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const enriched = await enrichMessage(msg.id);
|
||||
const members = await getMembersForSSE(conversationId, organizationId);
|
||||
|
||||
for (const { userId: memberId, orgId: memberOrgId } of members) {
|
||||
eventBus.publishEvent({
|
||||
type: "conv_message_created",
|
||||
data: { conversationId, message: enriched },
|
||||
organizationId: memberOrgId,
|
||||
userId: memberId,
|
||||
});
|
||||
}
|
||||
|
||||
const memberMuteRows = await db
|
||||
.select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId })
|
||||
.from(conversationMembers)
|
||||
.where(eq(conversationMembers.conversationId, conversationId));
|
||||
|
||||
const memberOrgMap = new Map(
|
||||
memberMuteRows.map(r => [r.userId, { orgId: r.externalOrgId ?? organizationId, mutedAt: r.mutedAt ?? null }]),
|
||||
);
|
||||
|
||||
notificationService.notifyMessengerMessage({
|
||||
conversationId,
|
||||
authorId: -1,
|
||||
authorName: "ВПН бот",
|
||||
chatName: "ВПН бот",
|
||||
chatType: "bot_direct",
|
||||
messageText: text.trim(),
|
||||
mentionedUserIds: [],
|
||||
memberOrgMap,
|
||||
}).catch(err => console.error("[VPN Bot] notify error:", err));
|
||||
}
|
||||
|
||||
async function getVpnBotId(organizationId: number): Promise<number | null> {
|
||||
const [bot] = await db
|
||||
.select({ id: bots.id })
|
||||
.from(bots)
|
||||
.where(and(eq(bots.organizationId, organizationId), eq(bots.login, VPN_BOT_LOGIN)))
|
||||
.limit(1);
|
||||
return bot?.id ?? null;
|
||||
}
|
||||
|
||||
// Simple in-memory state for the platform-selection step.
|
||||
// Key = conversationId. TTL cleanup runs every 5 minutes.
|
||||
const pendingPlatform = new Map<number, { roomUrl: string; createdAt: number }>();
|
||||
|
||||
function cleanPending() {
|
||||
const cutoff = Date.now() - 10 * 60 * 1000;
|
||||
for (const [key, value] of pendingPlatform.entries()) {
|
||||
if (value.createdAt < cutoff) pendingPlatform.delete(key);
|
||||
}
|
||||
}
|
||||
setInterval(cleanPending, 5 * 60 * 1000);
|
||||
|
||||
function detectPlatform(text: string): "android" | "ios" | null {
|
||||
const lower = text.toLowerCase();
|
||||
if (/\b(android|андроид|samsung|xiaomi|pixel|huawei|honор|poco|redmi|galaxy)\b/i.test(lower)) {
|
||||
return "android";
|
||||
}
|
||||
if (/\b(iphone|ios|айфон|ipad|apple)\b/i.test(lower)) {
|
||||
return "ios";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFileAttachment(filename: string): Attachment {
|
||||
const filePath = path.join("/app/apps", filename);
|
||||
let size = 0;
|
||||
try {
|
||||
size = fs.statSync(filePath).size;
|
||||
} catch (e) {
|
||||
console.error(`[VPN Bot] could not stat ${filePath}:`, e);
|
||||
}
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const mimeType = ext === ".apk" ? "application/vnd.android.package-archive" : "application/octet-stream";
|
||||
return {
|
||||
url: `${APPS_BASE_URL}/${filename}`,
|
||||
name: filename,
|
||||
size,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleVpnBotDirectMessage({
|
||||
bot,
|
||||
conversationId,
|
||||
message,
|
||||
user,
|
||||
organizationId,
|
||||
}: {
|
||||
bot: Bot;
|
||||
conversationId: number;
|
||||
message: string;
|
||||
user: User;
|
||||
organizationId: number;
|
||||
}): Promise<void> {
|
||||
const text = message.trim();
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
if (lower === "статус" || lower === "status") {
|
||||
await sendVpnBotMessage(
|
||||
conversationId,
|
||||
"Статус подписки пока не реализован. Пришлите ссылку на встречу для получения подписки.",
|
||||
organizationId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue platform-selection dialog
|
||||
const pending = pendingPlatform.get(conversationId);
|
||||
if (pending) {
|
||||
const platform = detectPlatform(text);
|
||||
if (!platform) {
|
||||
await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPlatform.delete(conversationId);
|
||||
|
||||
try {
|
||||
const userName = formatUserName(user);
|
||||
const result = await getOrCreateVpnSubscription(organizationId, user.id, userName, pending.roomUrl);
|
||||
|
||||
if (platform === "android") {
|
||||
await sendVpnBotMessage(
|
||||
conversationId,
|
||||
"Установи приложение olcbox из прикреплённого APK, затем добавь подписку по ссылке ниже.",
|
||||
organizationId,
|
||||
[getFileAttachment(ANDROID_FILE)],
|
||||
);
|
||||
} else {
|
||||
await sendVpnBotMessage(
|
||||
conversationId,
|
||||
`${IOS_WARNING}\n\nПосле установки добавь подписку по ссылке ниже.`,
|
||||
organizationId,
|
||||
[getFileAttachment(IOS_FILE)],
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionText = result.isNew
|
||||
? `Подписка готова:\n${result.subscriptionUrl}\n\nTelegram и WhatsApp пойдут через латвийский сервер, российские сайты — напрямую.\nСсылка привязана к первому устройству, на котором её активируют.`
|
||||
: `У вас уже есть подписка для этой комнаты:\n${result.subscriptionUrl}`;
|
||||
|
||||
await sendVpnBotMessage(conversationId, subscriptionText, organizationId);
|
||||
} catch (err: any) {
|
||||
console.error("[VPN Bot] handle message error:", err);
|
||||
const userMessage = err instanceof Error ? err.message : "Произошла ошибка. Попробуйте позже.";
|
||||
await sendVpnBotMessage(conversationId, userMessage, organizationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const roomUrl = extractRoomUrl(text);
|
||||
if (!roomUrl) {
|
||||
await sendVpnBotMessage(conversationId, INSTRUCTIONS_TEXT, organizationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start platform-selection dialog
|
||||
pendingPlatform.set(conversationId, { roomUrl, createdAt: Date.now() });
|
||||
await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId);
|
||||
}
|
||||
19
server/vpn/vpn.config.ts
Normal file
19
server/vpn/vpn.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const VPN_FORM_ID = 19;
|
||||
export const VPN_BOT_LOGIN = 'vpn_bot';
|
||||
export const VPN_BOT_NAME = 'ВПН бот';
|
||||
|
||||
export const SUBSCRIPTION_BASE_URL =
|
||||
process.env.VPN_SUBSCRIPTION_BASE_URL ||
|
||||
(process.env.APP_URL ? `${process.env.APP_URL}/api/vpn/sub` : 'https://iistwin.ru/api/vpn/sub');
|
||||
|
||||
export const VPN_TELEMOST_API_BASE =
|
||||
'https://cloud-api.yandex.ru/telemost_front/v2/telemost';
|
||||
|
||||
export const DEFAULT_TRAFFIC_LIMIT_GB = 100;
|
||||
export const DEFAULT_SUBSCRIPTION_DAYS = 30;
|
||||
export const SUBSCRIPTION_UPDATE_INTERVAL_HOURS = 1;
|
||||
|
||||
export const OLC_RTC_PROVIDER = 'telemost';
|
||||
export const OLC_RTC_TRANSPORT = 'vp8channel';
|
||||
export const OLC_RTC_VP8_FPS = 30;
|
||||
export const OLC_RTC_VP8_BATCH = 64;
|
||||
286
server/vpn/vpn.db.ts
Normal file
286
server/vpn/vpn.db.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { db } from '../db';
|
||||
import {
|
||||
formFields,
|
||||
formStatuses,
|
||||
tasks,
|
||||
taskFieldValues,
|
||||
type Task,
|
||||
type FormField,
|
||||
type FormStatus,
|
||||
} from '@shared/schema';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { storage } from '../storage';
|
||||
import { VPN_FORM_ID } from './vpn.config';
|
||||
|
||||
export interface VpnFormCache {
|
||||
fields: FormField[];
|
||||
statuses: FormStatus[];
|
||||
fieldByCode: Map<string, FormField>;
|
||||
statusByName: Map<string, FormStatus>;
|
||||
}
|
||||
|
||||
const cacheByOrg = new Map<number, VpnFormCache>();
|
||||
|
||||
export async function getVpnFormCache(organizationId: number): Promise<VpnFormCache> {
|
||||
if (cacheByOrg.has(organizationId)) {
|
||||
return cacheByOrg.get(organizationId)!;
|
||||
}
|
||||
|
||||
const [fields, statuses] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(formFields)
|
||||
.where(eq(formFields.formId, VPN_FORM_ID))
|
||||
.then(rows => rows as FormField[]),
|
||||
db
|
||||
.select()
|
||||
.from(formStatuses)
|
||||
.where(eq(formStatuses.formId, VPN_FORM_ID))
|
||||
.then(rows => rows as FormStatus[]),
|
||||
]);
|
||||
|
||||
const cache: VpnFormCache = {
|
||||
fields,
|
||||
statuses,
|
||||
fieldByCode: new Map(fields.map(f => [f.code, f])),
|
||||
statusByName: new Map(statuses.map(s => [s.name, s])),
|
||||
};
|
||||
cacheByOrg.set(organizationId, cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function getFieldId(cache: VpnFormCache, code: string): number {
|
||||
const f = cache.fieldByCode.get(code);
|
||||
if (!f) throw new Error(`VPN field not found: ${code}`);
|
||||
return f.id;
|
||||
}
|
||||
|
||||
export function getStatusId(cache: VpnFormCache, name: string): number {
|
||||
const s = cache.statusByName.get(name);
|
||||
if (!s) throw new Error(`VPN status not found: ${name}`);
|
||||
return s.id;
|
||||
}
|
||||
|
||||
export function clearVpnFormCache(organizationId: number): void {
|
||||
cacheByOrg.delete(organizationId);
|
||||
}
|
||||
|
||||
export interface VpnTaskData {
|
||||
task: Task;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function findVpnTaskByToken(
|
||||
token: string,
|
||||
organizationId: number,
|
||||
): Promise<VpnTaskData | null> {
|
||||
const cache = await getVpnFormCache(organizationId);
|
||||
const fieldId = getFieldId(cache, 'subscription_token');
|
||||
|
||||
const rows = await db
|
||||
.select({ task: tasks, value: taskFieldValues.value })
|
||||
.from(taskFieldValues)
|
||||
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||
.where(
|
||||
and(
|
||||
eq(taskFieldValues.fieldId, fieldId),
|
||||
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||
sql`${taskFieldValues.value} = to_jsonb(${token}::text)`,
|
||||
eq(tasks.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) return null;
|
||||
return loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||
}
|
||||
|
||||
export async function findVpnTaskByRoomUrl(
|
||||
roomUrl: string,
|
||||
organizationId: number,
|
||||
): Promise<VpnTaskData | null> {
|
||||
const cache = await getVpnFormCache(organizationId);
|
||||
const fieldId = getFieldId(cache, 'room_url');
|
||||
|
||||
const rows = await db
|
||||
.select({ task: tasks })
|
||||
.from(taskFieldValues)
|
||||
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||
.where(
|
||||
and(
|
||||
eq(taskFieldValues.fieldId, fieldId),
|
||||
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||
sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`,
|
||||
eq(tasks.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) return null;
|
||||
return loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||
}
|
||||
|
||||
export async function findVpnTaskByRoomAndDevice(
|
||||
roomUrl: string,
|
||||
deviceId: string,
|
||||
organizationId: number,
|
||||
): Promise<VpnTaskData | null> {
|
||||
const cache = await getVpnFormCache(organizationId);
|
||||
const roomFieldId = getFieldId(cache, 'room_url');
|
||||
|
||||
const rows = await db
|
||||
.select({ task: tasks })
|
||||
.from(taskFieldValues)
|
||||
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||
.where(
|
||||
and(
|
||||
eq(taskFieldValues.fieldId, roomFieldId),
|
||||
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||
sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`,
|
||||
eq(tasks.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) return null;
|
||||
const data = await loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||
if (data.values.hwid !== deviceId) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function findVpnTaskByRoomAndSession(
|
||||
roomUrl: string,
|
||||
sessionId: string,
|
||||
organizationId: number,
|
||||
): Promise<VpnTaskData | null> {
|
||||
const cache = await getVpnFormCache(organizationId);
|
||||
const roomFieldId = getFieldId(cache, 'room_url');
|
||||
|
||||
const rows = await db
|
||||
.select({ task: tasks })
|
||||
.from(taskFieldValues)
|
||||
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||||
.where(
|
||||
and(
|
||||
eq(taskFieldValues.fieldId, roomFieldId),
|
||||
eq(taskFieldValues.formId, VPN_FORM_ID),
|
||||
sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`,
|
||||
eq(tasks.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) return null;
|
||||
const data = await loadVpnTaskValues(rows[0].task.id, organizationId);
|
||||
if (data.values.active_session_id !== sessionId) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadVpnTaskValues(
|
||||
taskId: number,
|
||||
organizationId: number,
|
||||
): Promise<VpnTaskData> {
|
||||
const cache = await getVpnFormCache(organizationId);
|
||||
const [task, fvs] = await Promise.all([
|
||||
storage.getTask(taskId, organizationId),
|
||||
storage.getTaskFieldValues(taskId, organizationId),
|
||||
]);
|
||||
if (!task) throw new Error(`VPN task not found: ${taskId}`);
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const fv of fvs) {
|
||||
const field = cache.fields.find(f => f.id === fv.fieldId);
|
||||
if (field) {
|
||||
values[field.code] = fv.value;
|
||||
}
|
||||
}
|
||||
return { task, values };
|
||||
}
|
||||
|
||||
export async function setVpnTaskField(
|
||||
taskId: number,
|
||||
fieldCode: string,
|
||||
value: unknown,
|
||||
organizationId: number,
|
||||
): Promise<void> {
|
||||
const cache = await getVpnFormCache(organizationId);
|
||||
const fieldId = getFieldId(cache, fieldCode);
|
||||
|
||||
const existing = await db
|
||||
.select({ id: taskFieldValues.id })
|
||||
.from(taskFieldValues)
|
||||
.where(and(eq(taskFieldValues.taskId, taskId), eq(taskFieldValues.fieldId, fieldId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length) {
|
||||
await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value: value as any });
|
||||
} else {
|
||||
await storage.createTaskFieldValue({
|
||||
taskId,
|
||||
fieldId,
|
||||
formId: VPN_FORM_ID,
|
||||
value: value as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function setVpnTaskStatus(
|
||||
taskId: number,
|
||||
statusName: string,
|
||||
organizationId: number,
|
||||
): Promise<void> {
|
||||
const cache = await getVpnFormCache(organizationId);
|
||||
const statusId = getStatusId(cache, statusName);
|
||||
const status = cache.statusByName.get(statusName)!;
|
||||
await storage.updateTask(taskId, organizationId, {
|
||||
currentStatusId: statusId,
|
||||
isCompleted: status.isFinal,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createVpnTask(
|
||||
input: {
|
||||
organizationId: number;
|
||||
createdBy: number;
|
||||
title: string;
|
||||
statusName: string;
|
||||
fields: Record<string, unknown>;
|
||||
},
|
||||
): Promise<Task> {
|
||||
const cache = await getVpnFormCache(input.organizationId);
|
||||
const statusId = getStatusId(cache, input.statusName);
|
||||
|
||||
const task = await storage.createTask({
|
||||
organizationId: input.organizationId,
|
||||
formId: VPN_FORM_ID,
|
||||
title: input.title,
|
||||
currentStatusId: statusId,
|
||||
createdBy: input.createdBy,
|
||||
completedAt: null,
|
||||
dueDate: null,
|
||||
});
|
||||
|
||||
for (const [code, value] of Object.entries(input.fields)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
const fieldId = getFieldId(cache, code);
|
||||
await storage.createTaskFieldValue({
|
||||
taskId: task.id,
|
||||
fieldId,
|
||||
formId: VPN_FORM_ID,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function updateVpnTaskFieldMap(
|
||||
taskId: number,
|
||||
organizationId: number,
|
||||
fields: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
for (const [code, value] of Object.entries(fields)) {
|
||||
if (value === undefined) continue;
|
||||
await setVpnTaskField(taskId, code, value, organizationId);
|
||||
}
|
||||
}
|
||||
87
server/vpn/vpn.routes.ts
Normal file
87
server/vpn/vpn.routes.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
fetchSubscription,
|
||||
authorizeSession,
|
||||
closeSession,
|
||||
recordTraffic,
|
||||
VpnError,
|
||||
} from "./vpn.service";
|
||||
import { SUBSCRIPTION_UPDATE_INTERVAL_HOURS } from "./vpn.config";
|
||||
|
||||
function handleVpnError(res: Response, err: unknown): void {
|
||||
if (err instanceof VpnError) {
|
||||
res.status(403).type("text/plain").send(err.message);
|
||||
return;
|
||||
}
|
||||
console.error("[VPN API] error:", err);
|
||||
res.status(500).type("text/plain").send("Internal error");
|
||||
}
|
||||
|
||||
export function registerVpnRoutes(router: Router): void {
|
||||
// Subscription endpoint used by olcbox clients
|
||||
router.get("/api/vpn/sub/:token", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.params.token;
|
||||
const hwid = req.headers["x-hwid"] as string | undefined;
|
||||
const uri = await fetchSubscription(token, hwid);
|
||||
res.setHeader("profile-update-interval", String(SUBSCRIPTION_UPDATE_INTERVAL_HOURS));
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.send(uri);
|
||||
} catch (err) {
|
||||
handleVpnError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// olrtc srv AuthHook
|
||||
router.post("/api/vpn/olrtc/auth", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { device_id, room_url } = req.body || {};
|
||||
if (!device_id || !room_url) {
|
||||
res.status(400).json({ success: false, error: "missing device_id or room_url" });
|
||||
return;
|
||||
}
|
||||
const result = await authorizeSession(room_url, device_id);
|
||||
res.json({ success: true, session_id: result.sessionId });
|
||||
} catch (err) {
|
||||
if (err instanceof VpnError) {
|
||||
res.status(403).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
console.error("[VPN Auth] error:", err);
|
||||
res.status(500).json({ success: false, error: "internal error" });
|
||||
}
|
||||
});
|
||||
|
||||
// olrtc srv OnSessionClose
|
||||
router.post("/api/vpn/olrtc/close", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { session_id, room_url } = req.body || {};
|
||||
if (!session_id || !room_url) {
|
||||
res.status(400).json({ success: false, error: "missing fields" });
|
||||
return;
|
||||
}
|
||||
await closeSession(room_url, session_id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[VPN Close] error:", err);
|
||||
res.status(500).json({ success: false, error: "internal error" });
|
||||
}
|
||||
});
|
||||
|
||||
// olrtc srv OnTraffic
|
||||
router.post("/api/vpn/olrtc/traffic", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { session_id, room_url, bytes_in, bytes_out } = req.body || {};
|
||||
if (!session_id || !room_url || typeof bytes_in !== "number" || typeof bytes_out !== "number") {
|
||||
res.status(400).json({ success: false, error: "missing fields" });
|
||||
return;
|
||||
}
|
||||
await recordTraffic(room_url, session_id, bytes_in, bytes_out);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[VPN Traffic] error:", err);
|
||||
res.status(500).json({ success: false, error: "internal error" });
|
||||
}
|
||||
});
|
||||
}
|
||||
253
server/vpn/vpn.service.ts
Normal file
253
server/vpn/vpn.service.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
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);
|
||||
}
|
||||
|
||||
export async function validateTelemostRoom(roomUrl: string): Promise<boolean> {
|
||||
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/',
|
||||
},
|
||||
});
|
||||
return resp.status === 200;
|
||||
} catch (err) {
|
||||
console.error('[VPN] Telemost validation error:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateVpnSubscription(
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
userName: string,
|
||||
roomUrl: string,
|
||||
): Promise<{ subscriptionUrl: string; isNew: boolean }> {
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user