From 09dcd727396ca36982b1fa59a5b7a49da810c84f Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Tue, 9 Dec 2025 20:34:17 -0800 Subject: [PATCH] Add connection tracking service and endpoints --- .../src/controllers/settings.controller.ts | 91 +++++++++++ backend/src/middleware/optionalAuth.ts | 36 +++++ backend/src/routes/settings.routes.ts | 9 +- .../services/connection-tracker.service.ts | 142 ++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 backend/src/middleware/optionalAuth.ts create mode 100644 backend/src/services/connection-tracker.service.ts diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts index dfff1d2..6e44485 100644 --- a/backend/src/controllers/settings.controller.ts +++ b/backend/src/controllers/settings.controller.ts @@ -1,6 +1,8 @@ import { Response } from 'express'; import { AuthRequest } from '../types/index.js'; import { getSetting, setSetting } from '../config/database.js'; +import { connectionTracker } from '../services/connection-tracker.service.js'; +import crypto from 'crypto'; export async function getTimeLimit(req: AuthRequest, res: Response) { try { @@ -58,3 +60,92 @@ export async function setTimeLimit(req: AuthRequest, res: Response) { }); } } + +/** + * Heartbeat endpoint - clients ping this to indicate they're active + * Public endpoint - no auth required + * Session ID is sent from client (stored in localStorage) to ensure + * each browser window/tab has a unique connection tracked + */ +export async function heartbeat(req: AuthRequest, res: Response) { + try { + // Get session ID from request body (sent by client) + // Fallback to cookie for backwards compatibility + let sessionId = req.body.sessionId || req.cookies.session_id; + + if (!sessionId) { + // Generate a new session ID as fallback + sessionId = crypto.randomBytes(16).toString('hex'); + // Set cookie for backwards compatibility + res.cookie('session_id', sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000 + }); + } + + // Get route, video info, and time limit usage from request body + const route = req.body.route || '/'; + const videoTitle = req.body.videoTitle; + const videoChannel = req.body.videoChannel; + const timeUsed = req.body.timeUsed; + const dailyLimit = req.body.dailyLimit; + + // Register heartbeat (with user info if authenticated, current route, video info, and time limit usage) + connectionTracker.heartbeat( + sessionId, + req.userId, + req.username, + route, + videoTitle, + videoChannel, + timeUsed, + dailyLimit + ); + + res.json({ + success: true, + data: { + sessionId, + timestamp: Date.now() + } + }); + } catch (error: any) { + console.error('Heartbeat error:', error); + res.status(500).json({ + success: false, + error: { + code: 'HEARTBEAT_ERROR', + message: 'Error processing heartbeat' + } + }); + } +} + +/** + * Get connection stats - admin only + */ +export async function getConnectionStats(req: AuthRequest, res: Response) { + try { + const stats = connectionTracker.getStats(); + const connections = connectionTracker.getConnections(); + + res.json({ + success: true, + data: { + ...stats, + connections // Include full connection details with routes + } + }); + } catch (error: any) { + console.error('Get connection stats error:', error); + res.status(500).json({ + success: false, + error: { + code: 'GET_CONNECTION_STATS_ERROR', + message: 'Error fetching connection stats' + } + }); + } +} diff --git a/backend/src/middleware/optionalAuth.ts b/backend/src/middleware/optionalAuth.ts new file mode 100644 index 0000000..e409192 --- /dev/null +++ b/backend/src/middleware/optionalAuth.ts @@ -0,0 +1,36 @@ +import jwt from 'jsonwebtoken'; +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../types/index.js'; +import { env } from '../config/env.js'; + +/** + * Optional auth middleware - sets user info if token exists but doesn't fail if missing + * Useful for endpoints that work for both authenticated and anonymous users + */ +export function optionalAuthMiddleware( + req: AuthRequest, + res: Response, + next: NextFunction +) { + // Check for token in Authorization header or cookie + const token = req.cookies.auth_token || + req.headers.authorization?.replace('Bearer ', ''); + + if (token) { + try { + const decoded = jwt.verify(token, env.jwtSecret) as { + userId: number; + username: string; + }; + + req.userId = decoded.userId; + req.username = decoded.username; + } catch (error) { + // Invalid token - just continue without user info + // Don't set userId/username + } + } + + // Always continue - this middleware never fails + next(); +} diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 2e3f9ee..b250905 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; -import { getTimeLimit, setTimeLimit } from '../controllers/settings.controller.js'; +import { getTimeLimit, setTimeLimit, heartbeat, getConnectionStats } from '../controllers/settings.controller.js'; import { authMiddleware } from '../middleware/auth.js'; +import { optionalAuthMiddleware } from '../middleware/optionalAuth.js'; const router = Router(); @@ -10,4 +11,10 @@ router.get('/time-limit', getTimeLimit); // Protected route - only admins can set time limits router.put('/time-limit', authMiddleware, setTimeLimit); +// Public route - heartbeat for connection tracking (optional auth to track authenticated users) +router.post('/heartbeat', optionalAuthMiddleware, heartbeat); + +// Protected route - admin only - get connection stats +router.get('/connection-stats', authMiddleware, getConnectionStats); + export default router; diff --git a/backend/src/services/connection-tracker.service.ts b/backend/src/services/connection-tracker.service.ts new file mode 100644 index 0000000..1ee28af --- /dev/null +++ b/backend/src/services/connection-tracker.service.ts @@ -0,0 +1,142 @@ +/** + * Connection Tracker Service + * Tracks active user connections via heartbeat mechanism + */ + +interface Connection { + sessionId: string; + userId?: number; + username?: string; + route?: string; + videoTitle?: string; + videoChannel?: string; + timeUsed?: number; // minutes + dailyLimit?: number; // minutes + lastHeartbeat: number; + connectedAt: number; +} + +class ConnectionTracker { + private connections: Map = new Map(); + private readonly HEARTBEAT_TIMEOUT = 60 * 1000; // 60 seconds - consider connection dead if no heartbeat + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Clean up stale connections every 30 seconds + this.cleanupInterval = setInterval(() => { + this.cleanupStaleConnections(); + }, 30 * 1000); + } + + /** + * Register or update a connection heartbeat + */ + heartbeat(sessionId: string, userId?: number, username?: string, route?: string, videoTitle?: string, videoChannel?: string, timeUsed?: number, dailyLimit?: number): void { + const now = Date.now(); + const existing = this.connections.get(sessionId); + + if (existing) { + // Update existing connection + existing.lastHeartbeat = now; + if (userId !== undefined) existing.userId = userId; + if (username !== undefined) existing.username = username; + if (route !== undefined) existing.route = route; + if (videoTitle !== undefined) existing.videoTitle = videoTitle; + if (videoChannel !== undefined) existing.videoChannel = videoChannel; + if (timeUsed !== undefined) existing.timeUsed = timeUsed; + if (dailyLimit !== undefined) existing.dailyLimit = dailyLimit; + } else { + // New connection + this.connections.set(sessionId, { + sessionId, + userId, + username, + route, + videoTitle, + videoChannel, + timeUsed, + dailyLimit, + lastHeartbeat: now, + connectedAt: now + }); + } + } + + /** + * Remove a connection + */ + disconnect(sessionId: string): void { + this.connections.delete(sessionId); + } + + /** + * Get count of active connections + */ + getConnectionCount(): number { + this.cleanupStaleConnections(); + return this.connections.size; + } + + /** + * Get all active connections (for admin/debugging) + */ + getConnections(): Connection[] { + this.cleanupStaleConnections(); + return Array.from(this.connections.values()); + } + + /** + * Get connection stats + */ + getStats(): { + total: number; + authenticated: number; + anonymous: number; + } { + this.cleanupStaleConnections(); + const connections = Array.from(this.connections.values()); + const authenticated = connections.filter(c => c.userId !== undefined).length; + + return { + total: connections.length, + authenticated, + anonymous: connections.length - authenticated + }; + } + + /** + * Remove connections that haven't sent a heartbeat recently + */ + private cleanupStaleConnections(): void { + const now = Date.now(); + const stale: string[] = []; + + this.connections.forEach((conn, sessionId) => { + if (now - conn.lastHeartbeat > this.HEARTBEAT_TIMEOUT) { + stale.push(sessionId); + } + }); + + stale.forEach(sessionId => { + this.connections.delete(sessionId); + }); + + if (stale.length > 0) { + console.log(`[ConnectionTracker] Cleaned up ${stale.length} stale connection(s)`); + } + } + + /** + * Cleanup on shutdown + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.connections.clear(); + } +} + +// Singleton instance +export const connectionTracker = new ConnectionTracker();