Browse Source

Add connection tracking service and endpoints

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
09dcd72739
  1. 91
      backend/src/controllers/settings.controller.ts
  2. 36
      backend/src/middleware/optionalAuth.ts
  3. 9
      backend/src/routes/settings.routes.ts
  4. 142
      backend/src/services/connection-tracker.service.ts

91
backend/src/controllers/settings.controller.ts

@ -1,6 +1,8 @@ @@ -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) { @@ -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'
}
});
}
}

36
backend/src/middleware/optionalAuth.ts

@ -0,0 +1,36 @@ @@ -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();
}

9
backend/src/routes/settings.routes.ts

@ -1,6 +1,7 @@ @@ -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); @@ -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;

142
backend/src/services/connection-tracker.service.ts

@ -0,0 +1,142 @@ @@ -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<string, Connection> = 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();
Loading…
Cancel
Save