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:
@@ -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",
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|
||||||
@@ -69,7 +70,19 @@ export class AudioManager {
|
|||||||
this.silentGain.connect(this.ctx.destination);
|
this.silentGain.connect(this.ctx.destination);
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Generated
+12
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user