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": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@opencord/shared": "workspace:*",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"fastify": "^4.28.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"livekit-server-sdk": "^2.6.1",
|
||||
"cheerio": "^1.0.0"
|
||||
"livekit-server-sdk": "^2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
|
||||
@@ -14,6 +14,10 @@ function env(key: string, defaultValue?: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function envOptional(key: string): string | undefined {
|
||||
return process.env[key] || undefined;
|
||||
}
|
||||
|
||||
function envInt(key: string, defaultValue: number): number {
|
||||
const value = process.env[key];
|
||||
if (value === undefined) return defaultValue;
|
||||
@@ -33,13 +37,13 @@ function envBool(key: string, defaultValue: boolean): boolean {
|
||||
export const config = {
|
||||
port: envInt('PORT', 3000),
|
||||
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'),
|
||||
|
||||
livekit: {
|
||||
url: env('LIVEKIT_URL', 'wss://nova.ddns.net/livekit'),
|
||||
apiKey: env('LIVEKIT_API_KEY', 'REDACTED_LIVEKIT_KEY'),
|
||||
apiSecret: env('LIVEKIT_API_SECRET', 'REDACTED_LIVEKIT_SECRET'),
|
||||
url: envOptional('LIVEKIT_URL'),
|
||||
apiKey: envOptional('LIVEKIT_API_KEY'),
|
||||
apiSecret: envOptional('LIVEKIT_API_SECRET'),
|
||||
},
|
||||
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ 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', {
|
||||
id: text('id').primaryKey(),
|
||||
ownerId: text('owner_id'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import websocket from '@fastify/websocket';
|
||||
import multipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -34,6 +35,12 @@ async function main(): Promise<void> {
|
||||
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(multipart, {
|
||||
|
||||
@@ -19,7 +19,15 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
@@ -83,7 +91,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
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;
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
@@ -189,6 +189,7 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs
|
||||
type: 'dm_channel_created',
|
||||
dmChannel: {
|
||||
id: dmChannel.id,
|
||||
ownerId: dmChannel.ownerId ?? null,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: users.map(sanitizeUser),
|
||||
lastMessage: message,
|
||||
@@ -251,6 +252,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
dmChannels.push({
|
||||
id: dmChannel.id,
|
||||
ownerId: dmChannel.ownerId ?? null,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: users.map(sanitizeUser),
|
||||
lastMessage: lastMessage ? {
|
||||
@@ -359,6 +361,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const result: DmChannel = {
|
||||
id: dmChannel.id,
|
||||
ownerId: dmChannel.ownerId ?? null,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: users.map(sanitizeUser),
|
||||
lastMessage: lastMsg ? {
|
||||
@@ -381,6 +384,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
db.transaction((tx) => {
|
||||
tx.insert(schema.dmChannels).values({
|
||||
id: dmChannelId,
|
||||
ownerId: request.userId,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
@@ -402,6 +406,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const result: DmChannel = {
|
||||
id: dmChannelId,
|
||||
ownerId: request.userId,
|
||||
createdAt: now,
|
||||
members,
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
const targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get();
|
||||
if (!targetUser) {
|
||||
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
|
||||
const existingMembership = db.select()
|
||||
.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()
|
||||
: [];
|
||||
|
||||
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
|
||||
const lastMsgRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
@@ -537,6 +554,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const result: DmChannel = {
|
||||
id: dmChannel.id,
|
||||
ownerId: dmChannel.ownerId ?? null,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: users.map(sanitizeUser),
|
||||
lastMessage: lastMsg ? {
|
||||
@@ -776,6 +794,13 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/dm/:id/messages - Send a DM message
|
||||
app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', {
|
||||
preHandler: authenticate,
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5,
|
||||
timeWindow: '5 seconds',
|
||||
keyGenerator: (request: any) => request.userId || request.ip,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
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', {
|
||||
preHandler: authenticate,
|
||||
}, 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 };
|
||||
|
||||
// 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 livekitUrl = requestHost
|
||||
? `wss://${requestHost}/livekit`
|
||||
: config.livekit.url;
|
||||
: (config.livekit.url ?? '');
|
||||
|
||||
const response: LiveKitTokenResponse = {
|
||||
token: jwt,
|
||||
|
||||
@@ -252,6 +252,13 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/channels/:id/messages - Create a message
|
||||
app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', {
|
||||
preHandler: authenticate,
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5,
|
||||
timeWindow: '5 seconds',
|
||||
keyGenerator: (request: any) => request.userId || request.ip,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
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
|
||||
app.post('/api/uploads', {
|
||||
preHandler: authenticate,
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: '1 minute',
|
||||
keyGenerator: (request: any) => (request as any).userId || request.ip,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
|
||||
@@ -473,6 +473,35 @@ class 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): {
|
||||
user: User;
|
||||
servers: ServerWithChannelsAndMembers[];
|
||||
@@ -685,6 +714,7 @@ function buildReadyPayload(userId: string): {
|
||||
|
||||
dmChannels.push({
|
||||
id: dmChannel.id,
|
||||
ownerId: dmChannel.ownerId ?? null,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members,
|
||||
lastMessage: last ? {
|
||||
@@ -791,6 +821,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
let authenticated = false;
|
||||
let userId: string | undefined;
|
||||
let username: string | undefined;
|
||||
const rateLimiter = new WsRateLimiter();
|
||||
|
||||
// Set auth timeout - must authenticate within 10 seconds
|
||||
const authTimeout = setTimeout(() => {
|
||||
@@ -861,6 +892,12 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
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
|
||||
if (userId && username) {
|
||||
handleClientEvent(parsed, userId, username);
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface ActiveCallInfo {
|
||||
|
||||
export interface DmChannel {
|
||||
id: string;
|
||||
ownerId?: string | null;
|
||||
createdAt: number;
|
||||
members: User[];
|
||||
lastMessage?: DmMessage | null;
|
||||
|
||||
@@ -30,6 +30,7 @@ export class AudioManager {
|
||||
private stereoMerger: ChannelMergerNode | null = null;
|
||||
private rnnoiseEnabled = false;
|
||||
private rnnoiseReady = false;
|
||||
private keepAliveOscillator: OscillatorNode | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -70,6 +71,18 @@ export class AudioManager {
|
||||
|
||||
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 = () => {
|
||||
console.log(`[AudioManager] Context state: ${this.ctx?.state}`);
|
||||
if (this.ctx?.state === 'running') {
|
||||
|
||||
@@ -9,10 +9,48 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared'
|
||||
let globalWs: WebSocket | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let currentToken: string | null = null;
|
||||
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 {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
|
||||
@@ -351,13 +389,8 @@ function connect(): void {
|
||||
reconnectAttempts = 0;
|
||||
ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
|
||||
|
||||
// Start heartbeat to keep connection alive through proxies/NATs
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15_000);
|
||||
// Start heartbeat via Web Worker (immune to Safari background throttling)
|
||||
startHeartbeat(ws);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
@@ -371,10 +404,7 @@ function connect(): void {
|
||||
|
||||
ws.onclose = () => {
|
||||
globalWs = null;
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = undefined;
|
||||
}
|
||||
stopHeartbeat();
|
||||
if (currentToken) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
reconnectAttempts++;
|
||||
@@ -394,10 +424,7 @@ function disconnect(): void {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = undefined;
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = undefined;
|
||||
}
|
||||
stopHeartbeat();
|
||||
if (globalWs) {
|
||||
globalWs.close();
|
||||
globalWs = null;
|
||||
|
||||
Generated
+12
@@ -32,6 +32,9 @@ importers:
|
||||
'@fastify/multipart':
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.1
|
||||
'@fastify/rate-limit':
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.0
|
||||
'@fastify/static':
|
||||
specifier: ^7.0.4
|
||||
version: 7.0.4
|
||||
@@ -980,6 +983,9 @@ packages:
|
||||
'@fastify/multipart@8.3.1':
|
||||
resolution: {integrity: sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==}
|
||||
|
||||
'@fastify/rate-limit@9.1.0':
|
||||
resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==}
|
||||
|
||||
'@fastify/send@2.1.0':
|
||||
resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==}
|
||||
|
||||
@@ -4660,6 +4666,12 @@ snapshots:
|
||||
secure-json-parse: 2.7.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':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
|
||||
Reference in New Issue
Block a user