fix: security hardening and Safari stability

- Remove hardcoded JWT_SECRET fallback (crash on boot if unset)
- Make LiveKit config optional with 503 guard on token endpoint
- Add REST rate limiting via @fastify/rate-limit (auth 10/15m, messages 5/5s, uploads 10/1m, global 60/1m)
- Add WebSocket token bucket rate limiter (30 burst, 2/sec refill)
- Add DM channel ownership (ownerId) with migration, enforce on add-member
- Require friendship to add users to group DMs
- Add silent 20Hz oscillator to prevent Safari AudioContext suspension
- Move WebSocket heartbeat to Web Worker to bypass Safari background throttling
This commit is contained in:
Jannis Braun
2026-02-24 04:34:36 +01:00
parent 36e27121da
commit 024833c470
16 changed files with 205 additions and 36 deletions
+3 -2
View File
@@ -12,17 +12,18 @@
"dependencies": { "dependencies": {
"@fastify/cors": "^9.0.1", "@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0", "@fastify/multipart": "^8.3.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",
"@opencord/shared": "workspace:*", "@opencord/shared": "workspace:*",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"cheerio": "^1.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.6.1", "livekit-server-sdk": "^2.6.1"
"cheerio": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
+8 -4
View File
@@ -14,6 +14,10 @@ function env(key: string, defaultValue?: string): string {
return value; return value;
} }
function envOptional(key: string): string | undefined {
return process.env[key] || undefined;
}
function envInt(key: string, defaultValue: number): number { function envInt(key: string, defaultValue: number): number {
const value = process.env[key]; const value = process.env[key];
if (value === undefined) return defaultValue; if (value === undefined) return defaultValue;
@@ -33,13 +37,13 @@ function envBool(key: string, defaultValue: boolean): boolean {
export const config = { export const config = {
port: envInt('PORT', 3000), port: envInt('PORT', 3000),
host: env('HOST', '0.0.0.0'), host: env('HOST', '0.0.0.0'),
jwtSecret: env('JWT_SECRET', 'dev-secret-change-me-in-production-please-use-64-chars-hex-string'), jwtSecret: env('JWT_SECRET'),
jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'), jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'),
livekit: { livekit: {
url: env('LIVEKIT_URL', 'wss://nova.ddns.net/livekit'), url: envOptional('LIVEKIT_URL'),
apiKey: env('LIVEKIT_API_KEY', 'REDACTED_LIVEKIT_KEY'), apiKey: envOptional('LIVEKIT_API_KEY'),
apiSecret: env('LIVEKIT_API_SECRET', 'REDACTED_LIVEKIT_SECRET'), apiSecret: envOptional('LIVEKIT_API_SECRET'),
}, },
uploadDir: env('UPLOAD_DIR', resolve(__dirname, '../../../data/uploads')), uploadDir: env('UPLOAD_DIR', resolve(__dirname, '../../../data/uploads')),
+1
View File
@@ -76,6 +76,7 @@ function createTables(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS dm_channels ( CREATE TABLE IF NOT EXISTS dm_channels (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
owner_id TEXT,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
+6
View File
@@ -41,6 +41,12 @@ export function runMigrations(db: Database.Database): void {
columns: [ columns: [
{ name: 'closed', type: 'INTEGER DEFAULT 0' } { name: 'closed', type: 'INTEGER DEFAULT 0' }
] ]
},
{
name: 'dm_channels',
columns: [
{ name: 'owner_id', type: 'TEXT' }
]
} }
]; ];
+1
View File
@@ -68,6 +68,7 @@ export const attachments = sqliteTable('attachments', {
export const dmChannels = sqliteTable('dm_channels', { export const dmChannels = sqliteTable('dm_channels', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
ownerId: text('owner_id'),
createdAt: integer('created_at').notNull(), createdAt: integer('created_at').notNull(),
}); });
+7
View File
@@ -1,5 +1,6 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import websocket from '@fastify/websocket'; import websocket from '@fastify/websocket';
import multipart from '@fastify/multipart'; import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
@@ -34,6 +35,12 @@ async function main(): Promise<void> {
allowedHeaders: ['Content-Type', 'Authorization'], allowedHeaders: ['Content-Type', 'Authorization'],
}); });
await app.register(rateLimit, {
max: 60,
timeWindow: '1 minute',
keyGenerator: (request) => (request as any).userId || request.ip,
});
await app.register(websocket); await app.register(websocket);
await app.register(multipart, { await app.register(multipart, {
+18 -2
View File
@@ -19,7 +19,15 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
} }
export async function authRoutes(app: FastifyInstance): Promise<void> { export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: RegisterRequest }>('/api/auth/register', async (request, reply) => { app.post<{ Body: RegisterRequest }>('/api/auth/register', {
config: {
rateLimit: {
max: 10,
timeWindow: '15 minutes',
keyGenerator: (request: any) => request.ip,
},
},
}, async (request, reply) => {
const { username, password, displayName } = request.body; const { username, password, displayName } = request.body;
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
@@ -83,7 +91,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.code(201).send(response); return reply.code(201).send(response);
}); });
app.post<{ Body: LoginRequest }>('/api/auth/login', async (request, reply) => { app.post<{ Body: LoginRequest }>('/api/auth/login', {
config: {
rateLimit: {
max: 10,
timeWindow: '15 minutes',
keyGenerator: (request: any) => request.ip,
},
},
}, async (request, reply) => {
const { username, password } = request.body; const { username, password } = request.body;
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
+35 -10
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { eq, and, desc, lt, inArray } from 'drizzle-orm'; import { eq, and, or, desc, lt, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js'; import { generateSnowflake } from '../utils/snowflake.js';
@@ -189,6 +189,7 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs
type: 'dm_channel_created', type: 'dm_channel_created',
dmChannel: { dmChannel: {
id: dmChannel.id, id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt, createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser), members: users.map(sanitizeUser),
lastMessage: message, lastMessage: message,
@@ -251,6 +252,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
dmChannels.push({ dmChannels.push({
id: dmChannel.id, id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt, createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser), members: users.map(sanitizeUser),
lastMessage: lastMessage ? { lastMessage: lastMessage ? {
@@ -359,6 +361,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const result: DmChannel = { const result: DmChannel = {
id: dmChannel.id, id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt, createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser), members: users.map(sanitizeUser),
lastMessage: lastMsg ? { lastMessage: lastMsg ? {
@@ -381,6 +384,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
db.transaction((tx) => { db.transaction((tx) => {
tx.insert(schema.dmChannels).values({ tx.insert(schema.dmChannels).values({
id: dmChannelId, id: dmChannelId,
ownerId: request.userId,
createdAt: now, createdAt: now,
}).run(); }).run();
@@ -402,6 +406,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const result: DmChannel = { const result: DmChannel = {
id: dmChannelId, id: dmChannelId,
ownerId: request.userId,
createdAt: now, createdAt: now,
members, members,
lastMessage: null, lastMessage: null,
@@ -472,12 +477,33 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
} }
// Enforce DM channel ownership: only the owner can add members (for new-style group DMs)
const dmChannel = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, id)).get();
if (!dmChannel) {
return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 });
}
if (dmChannel.ownerId && dmChannel.ownerId !== request.userId) {
return reply.code(403).send({ error: 'Only the group owner can add members', statusCode: 403 });
}
// Validate target user exists // Validate target user exists
const targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get(); const targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get();
if (!targetUser) { if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 }); return reply.code(404).send({ error: 'User not found', statusCode: 404 });
} }
// Validate the adder and target are friends
const friendship = db.select().from(schema.friends).where(
or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUserId)),
and(eq(schema.friends.userId, targetUserId), eq(schema.friends.friendId, request.userId)),
),
).get();
if (!friendship) {
return reply.code(403).send({ error: 'You can only add friends to group DMs', statusCode: 403 });
}
// Validate target is not already a member // Validate target is not already a member
const existingMembership = db.select() const existingMembership = db.select()
.from(schema.dmMembers) .from(schema.dmMembers)
@@ -517,15 +543,6 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
: []; : [];
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, id))
.get();
if (!dmChannel) {
return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 });
}
// Fetch last message // Fetch last message
const lastMsgRows = db.select() const lastMsgRows = db.select()
.from(schema.dmMessages) .from(schema.dmMessages)
@@ -537,6 +554,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const result: DmChannel = { const result: DmChannel = {
id: dmChannel.id, id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt, createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser), members: users.map(sanitizeUser),
lastMessage: lastMsg ? { lastMessage: lastMsg ? {
@@ -776,6 +794,13 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
// POST /api/dm/:id/messages - Send a DM message // POST /api/dm/:id/messages - Send a DM message
app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', { app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', {
preHandler: authenticate, preHandler: authenticate,
config: {
rateLimit: {
max: 5,
timeWindow: '5 seconds',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body; const { content, attachments: attachmentIds, replyToId } = request.body;
+5 -1
View File
@@ -9,6 +9,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: LiveKitTokenRequest & { dmChannelId?: string } }>('/api/livekit/token', { app.post<{ Body: LiveKitTokenRequest & { dmChannelId?: string } }>('/api/livekit/token', {
preHandler: authenticate, preHandler: authenticate,
}, async (request, reply) => { }, async (request, reply) => {
if (!config.livekit.apiKey || !config.livekit.apiSecret) {
return reply.code(503).send({ error: 'Voice/video is not configured on this server', statusCode: 503 });
}
const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string }; const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string };
// Determine room name based on channel type // Determine room name based on channel type
@@ -54,7 +58,7 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
const requestHost = request.headers.host?.replace(/:\d+$/, '') || ''; const requestHost = request.headers.host?.replace(/:\d+$/, '') || '';
const livekitUrl = requestHost const livekitUrl = requestHost
? `wss://${requestHost}/livekit` ? `wss://${requestHost}/livekit`
: config.livekit.url; : (config.livekit.url ?? '');
const response: LiveKitTokenResponse = { const response: LiveKitTokenResponse = {
token: jwt, token: jwt,
+7
View File
@@ -252,6 +252,13 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
// POST /api/channels/:id/messages - Create a message // POST /api/channels/:id/messages - Create a message
app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', { app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', {
preHandler: authenticate, preHandler: authenticate,
config: {
rateLimit: {
max: 5,
timeWindow: '5 seconds',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body; const { content, attachments: attachmentIds, replyToId } = request.body;
+7
View File
@@ -18,6 +18,13 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
// POST /api/uploads - Upload a file // POST /api/uploads - Upload a file
app.post('/api/uploads', { app.post('/api/uploads', {
preHandler: authenticate, preHandler: authenticate,
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
keyGenerator: (request: any) => (request as any).userId || request.ip,
},
},
}, async (request, reply) => { }, async (request, reply) => {
const data = await request.file(); const data = await request.file();
if (!data) { if (!data) {
+37
View File
@@ -473,6 +473,35 @@ class ConnectionManager {
export const connectionManager = new ConnectionManager(); export const connectionManager = new ConnectionManager();
// ─── WebSocket Rate Limiter (Token Bucket) ─────────────────────────────────
class WsRateLimiter {
private tokens: number;
private readonly maxTokens: number;
private readonly refillRate: number; // tokens per second
private lastRefill: number;
constructor(maxTokens = 30, refillRate = 2) {
this.maxTokens = maxTokens;
this.tokens = maxTokens;
this.refillRate = refillRate;
this.lastRefill = Date.now();
}
consume(): boolean {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
}
function buildReadyPayload(userId: string): { function buildReadyPayload(userId: string): {
user: User; user: User;
servers: ServerWithChannelsAndMembers[]; servers: ServerWithChannelsAndMembers[];
@@ -685,6 +714,7 @@ function buildReadyPayload(userId: string): {
dmChannels.push({ dmChannels.push({
id: dmChannel.id, id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt, createdAt: dmChannel.createdAt,
members, members,
lastMessage: last ? { lastMessage: last ? {
@@ -791,6 +821,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
let authenticated = false; let authenticated = false;
let userId: string | undefined; let userId: string | undefined;
let username: string | undefined; let username: string | undefined;
const rateLimiter = new WsRateLimiter();
// Set auth timeout - must authenticate within 10 seconds // Set auth timeout - must authenticate within 10 seconds
const authTimeout = setTimeout(() => { const authTimeout = setTimeout(() => {
@@ -861,6 +892,12 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
return; return;
} }
// Rate limit all post-auth, non-ping messages
if (!rateLimiter.consume()) {
ws.send(JSON.stringify({ type: 'error', message: 'Rate limited' }));
return;
}
// Handle authenticated events // Handle authenticated events
if (userId && username) { if (userId && username) {
handleClientEvent(parsed, userId, username); handleClientEvent(parsed, userId, username);
+1
View File
@@ -149,6 +149,7 @@ export interface ActiveCallInfo {
export interface DmChannel { export interface DmChannel {
id: string; id: string;
ownerId?: string | null;
createdAt: number; createdAt: number;
members: User[]; members: User[];
lastMessage?: DmMessage | null; lastMessage?: DmMessage | null;
+13
View File
@@ -30,6 +30,7 @@ export class AudioManager {
private stereoMerger: ChannelMergerNode | null = null; private stereoMerger: ChannelMergerNode | null = null;
private rnnoiseEnabled = false; private rnnoiseEnabled = false;
private rnnoiseReady = false; private rnnoiseReady = false;
private keepAliveOscillator: OscillatorNode | null = null;
private constructor() {} private constructor() {}
@@ -70,6 +71,18 @@ export class AudioManager {
this.inputGain.gain.setValueAtTime(1, this.ctx.currentTime); this.inputGain.gain.setValueAtTime(1, this.ctx.currentTime);
// Safari suspends the AudioContext when it detects no audible output,
// even while WebRTC audio is flowing through the pipeline. A sub-bass
// oscillator at near-zero gain keeps the rendering thread alive without
// producing audible sound.
this.keepAliveOscillator = this.ctx.createOscillator();
this.keepAliveOscillator.frequency.value = 20;
const keepAliveGain = this.ctx.createGain();
keepAliveGain.gain.value = 0.00001;
this.keepAliveOscillator.connect(keepAliveGain);
keepAliveGain.connect(this.ctx.destination);
this.keepAliveOscillator.start();
this.ctx.onstatechange = () => { this.ctx.onstatechange = () => {
console.log(`[AudioManager] Context state: ${this.ctx?.state}`); console.log(`[AudioManager] Context state: ${this.ctx?.state}`);
if (this.ctx?.state === 'running') { if (this.ctx?.state === 'running') {
+43 -16
View File
@@ -9,10 +9,48 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared'
let globalWs: WebSocket | null = null; let globalWs: WebSocket | null = null;
let reconnectAttempts = 0; let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined; let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
let currentToken: string | null = null; let currentToken: string | null = null;
let isInitialized = false; let isInitialized = false;
// Worker-based heartbeat: Safari throttles main-thread setInterval in
// background tabs, causing ping timeouts. A Web Worker's timers run on a
// separate thread and are not subject to the same throttling.
let heartbeatWorker: Worker | null = null;
function createHeartbeatWorker(): Worker {
const blob = new Blob([`
let timerId = null;
self.onmessage = function(e) {
if (e.data === 'start') {
if (timerId) clearInterval(timerId);
timerId = setInterval(function() { self.postMessage('tick'); }, 15000);
} else if (e.data === 'stop') {
if (timerId) { clearInterval(timerId); timerId = null; }
}
};
`], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob));
}
function startHeartbeat(ws: WebSocket): void {
stopHeartbeat();
heartbeatWorker = createHeartbeatWorker();
heartbeatWorker.onmessage = () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
};
heartbeatWorker.postMessage('start');
}
function stopHeartbeat(): void {
if (heartbeatWorker) {
heartbeatWorker.postMessage('stop');
heartbeatWorker.terminate();
heartbeatWorker = null;
}
}
function handleEvent(event: ServerEvent): void { function handleEvent(event: ServerEvent): void {
const { setUser } = useAuthStore.getState(); const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState(); const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
@@ -351,13 +389,8 @@ function connect(): void {
reconnectAttempts = 0; reconnectAttempts = 0;
ws.send(JSON.stringify({ type: 'auth', token: currentToken })); ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
// Start heartbeat to keep connection alive through proxies/NATs // Start heartbeat via Web Worker (immune to Safari background throttling)
if (heartbeatInterval) clearInterval(heartbeatInterval); startHeartbeat(ws);
heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 15_000);
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
@@ -371,10 +404,7 @@ function connect(): void {
ws.onclose = () => { ws.onclose = () => {
globalWs = null; globalWs = null;
if (heartbeatInterval) { stopHeartbeat();
clearInterval(heartbeatInterval);
heartbeatInterval = undefined;
}
if (currentToken) { if (currentToken) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++; reconnectAttempts++;
@@ -394,10 +424,7 @@ function disconnect(): void {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
reconnectTimer = undefined; reconnectTimer = undefined;
} }
if (heartbeatInterval) { stopHeartbeat();
clearInterval(heartbeatInterval);
heartbeatInterval = undefined;
}
if (globalWs) { if (globalWs) {
globalWs.close(); globalWs.close();
globalWs = null; globalWs = null;
+12
View File
@@ -32,6 +32,9 @@ importers:
'@fastify/multipart': '@fastify/multipart':
specifier: ^8.3.0 specifier: ^8.3.0
version: 8.3.1 version: 8.3.1
'@fastify/rate-limit':
specifier: ^9.1.0
version: 9.1.0
'@fastify/static': '@fastify/static':
specifier: ^7.0.4 specifier: ^7.0.4
version: 7.0.4 version: 7.0.4
@@ -980,6 +983,9 @@ packages:
'@fastify/multipart@8.3.1': '@fastify/multipart@8.3.1':
resolution: {integrity: sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==} resolution: {integrity: sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==}
'@fastify/rate-limit@9.1.0':
resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==}
'@fastify/send@2.1.0': '@fastify/send@2.1.0':
resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==}
@@ -4660,6 +4666,12 @@ snapshots:
secure-json-parse: 2.7.0 secure-json-parse: 2.7.0
stream-wormhole: 1.1.0 stream-wormhole: 1.1.0
'@fastify/rate-limit@9.1.0':
dependencies:
'@lukeed/ms': 2.0.2
fastify-plugin: 4.5.1
toad-cache: 3.7.0
'@fastify/send@2.1.0': '@fastify/send@2.1.0':
dependencies: dependencies:
'@lukeed/ms': 2.0.2 '@lukeed/ms': 2.0.2