Addresses data exposure, access control, input validation, infrastructure hardening, and supply chain security issues identified during audit. Key changes: - Strip internal fields from public campaign/profile/comment endpoints - Restrict docs routes to CONTENT_ROLES, provisioning to SUPER_ADMIN - Add SSE connection limits, social middleware fail-closed behavior - Bind all non-nginx ports to 127.0.0.1, pin container image versions - Add CSP header, conditional HSTS, token redaction in nginx logs - Validate nav URLs, calendar schemas, video tracking batch events - Reject default admin password placeholder, add SSRF protocol checks - Exclude .env from Code Server, enforce RC admin password in compose - Add Zod validation for achievement grant/revoke, webhook secret header - Fix path traversal prefix attack, add calendar token expiry Bunker Admin
139 lines
3.7 KiB
TypeScript
139 lines
3.7 KiB
TypeScript
import type { Response } from 'express';
|
|
import { logger } from '../../utils/logger';
|
|
|
|
interface SSEClient {
|
|
id: string;
|
|
userId: string;
|
|
res: Response;
|
|
connectedAt: Date;
|
|
}
|
|
|
|
/** In-memory SSE connection manager (single-node) */
|
|
class SSEService {
|
|
private clients = new Map<string, SSEClient[]>(); // userId → client connections
|
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
|
|
|
/** Start heartbeat interval (30s keep-alive) */
|
|
startHeartbeat() {
|
|
if (this.heartbeatInterval) return;
|
|
this.heartbeatInterval = setInterval(() => {
|
|
let total = 0;
|
|
for (const [, clients] of this.clients) {
|
|
for (const client of clients) {
|
|
try {
|
|
client.res.write(': heartbeat\n\n');
|
|
total++;
|
|
} catch {
|
|
this.removeClient(client.id);
|
|
}
|
|
}
|
|
}
|
|
if (total > 0) {
|
|
logger.debug(`SSE heartbeat sent to ${total} clients`);
|
|
}
|
|
}, 30_000);
|
|
}
|
|
|
|
/** Stop heartbeat */
|
|
stopHeartbeat() {
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
this.heartbeatInterval = null;
|
|
}
|
|
}
|
|
|
|
/** Add an SSE client */
|
|
addClient(userId: string, res: Response): string {
|
|
const id = `${userId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
const client: SSEClient = { id, userId, res, connectedAt: new Date() };
|
|
|
|
if (!this.clients.has(userId)) {
|
|
this.clients.set(userId, []);
|
|
}
|
|
this.clients.get(userId)!.push(client);
|
|
|
|
logger.info(`SSE client connected: ${id} (user: ${userId})`);
|
|
return id;
|
|
}
|
|
|
|
/** Remove an SSE client by connection ID */
|
|
removeClient(connectionId: string) {
|
|
for (const [userId, clients] of this.clients) {
|
|
const idx = clients.findIndex((c) => c.id === connectionId);
|
|
if (idx >= 0) {
|
|
clients.splice(idx, 1);
|
|
if (clients.length === 0) {
|
|
this.clients.delete(userId);
|
|
}
|
|
logger.info(`SSE client disconnected: ${connectionId} (user: ${userId})`);
|
|
return userId;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Send an event to a specific user (all connections) */
|
|
sendToUser(userId: string, event: string, data: unknown) {
|
|
const clients = this.clients.get(userId);
|
|
if (!clients || clients.length === 0) return false;
|
|
|
|
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
|
|
for (const client of clients) {
|
|
try {
|
|
client.res.write(payload);
|
|
} catch {
|
|
this.removeClient(client.id);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Broadcast an event to a list of user IDs */
|
|
sendToUsers(userIds: string[], event: string, data: unknown) {
|
|
for (const userId of userIds) {
|
|
this.sendToUser(userId, event, data);
|
|
}
|
|
}
|
|
|
|
/** Check if a user has any active SSE connections */
|
|
isConnected(userId: string): boolean {
|
|
const clients = this.clients.get(userId);
|
|
return !!clients && clients.length > 0;
|
|
}
|
|
|
|
/** Get all currently connected user IDs */
|
|
getConnectedUserIds(): string[] {
|
|
return Array.from(this.clients.keys());
|
|
}
|
|
|
|
/** Get connection count */
|
|
getConnectionCount(): number {
|
|
let count = 0;
|
|
for (const clients of this.clients.values()) {
|
|
count += clients.length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/** Get connection count for a specific user */
|
|
getConnectionCountForUser(userId: string): number {
|
|
return this.clients.get(userId)?.length ?? 0;
|
|
}
|
|
|
|
/** Close all connections (graceful shutdown) */
|
|
closeAll() {
|
|
this.stopHeartbeat();
|
|
for (const [, clients] of this.clients) {
|
|
for (const client of clients) {
|
|
try { client.res.end(); } catch {}
|
|
}
|
|
}
|
|
this.clients.clear();
|
|
logger.info('SSE: All connections closed');
|
|
}
|
|
}
|
|
|
|
export const sseService = new SSEService();
|