From c9beee9a4e7d1f4cec6ab6136dc91cd9e0932972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Tue, 2 Dec 2025 19:47:47 +0100 Subject: [PATCH] feat(admin): add Activity Log backend services (Phase 2) Core services for activity logging system: 1. ActivityLog Service (backend/src/services/activityLog.js) - Centralized logging with fire-and-forget pattern - 18 action constants (auth, event, match, admin, chat) - Query interface with filtering (date, action, user, category) - Socket.IO emission for real-time streaming - Statistics and action types endpoints - Never throws - logging cannot crash app 2. Request Utility (backend/src/utils/request.js) - getClientIP() - Extract client IP from headers/socket - Handles X-Forwarded-For and X-Real-IP proxy headers - IPv6-mapped IPv4 conversion 3. Admin Middleware (backend/src/middleware/admin.js) - requireAdmin() - Protect admin routes - Fresh DB check of isAdmin flag - Returns 403 for non-admin users - Use after authenticate middleware Next phases: logging integration points, API endpoints, frontend UI --- backend/src/middleware/admin.js | 73 +++++++ backend/src/services/activityLog.js | 322 ++++++++++++++++++++++++++++ backend/src/utils/request.js | 52 +++++ 3 files changed, 447 insertions(+) create mode 100644 backend/src/middleware/admin.js create mode 100644 backend/src/services/activityLog.js create mode 100644 backend/src/utils/request.js diff --git a/backend/src/middleware/admin.js b/backend/src/middleware/admin.js new file mode 100644 index 0000000..69af632 --- /dev/null +++ b/backend/src/middleware/admin.js @@ -0,0 +1,73 @@ +/** + * Admin Middleware + * Protects admin-only routes by verifying user has admin privileges + */ + +const { prisma } = require('../utils/db'); + +/** + * Require admin middleware + * Must be used AFTER authenticate middleware + * Checks if authenticated user has isAdmin flag set to true + * + * Usage: + * router.get('/admin/something', authenticate, requireAdmin, handler); + * + * @param {Object} req - Express request (must have req.user from authenticate) + * @param {Object} res - Express response + * @param {Function} next - Express next function + */ +async function requireAdmin(req, res, next) { + try { + // Check if user is authenticated + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + error: 'Authentication required', + }); + } + + // Fetch fresh user data from database to check admin status + // (Don't trust potentially stale req.user data) + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { id: true, isAdmin: true, username: true, email: true }, + }); + + // Check if user exists + if (!user) { + return res.status(401).json({ + success: false, + error: 'User not found', + }); + } + + // Check if user is admin + if (!user.isAdmin) { + console.warn(`🚫 Non-admin user ${user.username} attempted to access admin route: ${req.path}`); + return res.status(403).json({ + success: false, + error: 'Admin access required', + message: 'You do not have permission to access this resource', + }); + } + + // User is admin - allow access + console.log(`✅ Admin ${user.username} accessing: ${req.method} ${req.path}`); + + // Update req.user with fresh admin flag + req.user.isAdmin = true; + + next(); + } catch (error) { + console.error('Error in requireAdmin middleware:', error); + return res.status(500).json({ + success: false, + error: 'Internal server error during admin check', + }); + } +} + +module.exports = { + requireAdmin, +}; diff --git a/backend/src/services/activityLog.js b/backend/src/services/activityLog.js new file mode 100644 index 0000000..f181e14 --- /dev/null +++ b/backend/src/services/activityLog.js @@ -0,0 +1,322 @@ +/** + * Activity Log Service + * Centralized logging for all user actions with real-time Socket.IO streaming + */ + +const { prisma } = require('../utils/db'); + +// Action constants for consistency +const ACTIONS = { + // Auth actions + AUTH_REGISTER: 'auth.register', + AUTH_LOGIN: 'auth.login', + AUTH_LOGOUT: 'auth.logout', + AUTH_VERIFY_EMAIL: 'auth.verify_email', + AUTH_PASSWORD_RESET: 'auth.password_reset', + + // Event actions + EVENT_CHECKIN: 'event.checkin', + EVENT_LEAVE: 'event.leave', + EVENT_JOIN_CHAT: 'event.join_chat', + EVENT_LEAVE_CHAT: 'event.leave_chat', + + // Match actions + MATCH_CREATE: 'match.create', + MATCH_ACCEPT: 'match.accept', + MATCH_REJECT: 'match.reject', + MATCH_COMPLETE: 'match.complete', + + // Admin actions + ADMIN_MATCHING_RUN: 'admin.matching_run', + ADMIN_VIEW_LOGS: 'admin.view_logs', + + // Chat actions + CHAT_MESSAGE: 'chat.message', + CHAT_JOIN_ROOM: 'chat.join_room', + CHAT_LEAVE_ROOM: 'chat.leave_room', +}; + +// Category mapping from action +const CATEGORIES = { + 'auth': 'auth', + 'event': 'event', + 'match': 'match', + 'admin': 'admin', + 'chat': 'chat', +}; + +/** + * Extract category from action string + * @param {string} action - Action string (e.g., 'auth.login') + * @returns {string} - Category (e.g., 'auth') + */ +function getCategoryFromAction(action) { + const prefix = action.split('.')[0]; + return CATEGORIES[prefix] || 'other'; +} + +/** + * Log an activity entry + * Fire-and-forget pattern - never throws errors to prevent app crashes + * + * @param {Object} params - Log parameters + * @param {number} params.userId - User ID (optional for system actions) + * @param {string} params.username - Username (denormalized) + * @param {string} params.ipAddress - Client IP address + * @param {string} params.action - Action constant (use ACTIONS.*) + * @param {string} params.resource - Resource identifier (e.g., 'event:123') + * @param {string} params.method - HTTP method (GET, POST, etc.) + * @param {string} params.path - API path + * @param {Object} params.metadata - Additional data (JSON) + * @param {boolean} params.success - Success flag (default: true) + * @param {string} params.errorMessage - Error message if failed + * @returns {Promise} + */ +async function log({ + userId = null, + username = null, + ipAddress = null, + action, + resource = null, + method = null, + path = null, + metadata = null, + success = true, + errorMessage = null, +}) { + try { + // Validate required fields + if (!action) { + console.warn('ActivityLog.log() called without action - skipping'); + return; + } + + const category = getCategoryFromAction(action); + + // Create log entry + const logEntry = await prisma.activityLog.create({ + data: { + userId, + username, + ipAddress, + action, + category, + resource, + method, + path, + metadata: metadata ? JSON.parse(JSON.stringify(metadata)) : null, + success, + errorMessage, + }, + }); + + // Emit to Socket.IO for real-time streaming + try { + const { getIO } = require('../socket'); + const io = getIO(); + + // Emit to admin activity logs room + io.to('admin_activity_logs').emit('activity_log_entry', { + id: logEntry.id, + userId: logEntry.userId, + username: logEntry.username, + ipAddress: logEntry.ipAddress, + action: logEntry.action, + category: logEntry.category, + resource: logEntry.resource, + method: logEntry.method, + path: logEntry.path, + metadata: logEntry.metadata, + success: logEntry.success, + errorMessage: logEntry.errorMessage, + createdAt: logEntry.createdAt.toISOString(), + }); + } catch (socketError) { + // Socket.IO may not be initialized yet - log but don't fail + console.debug('Socket.IO not available for activity log emission:', socketError.message); + } + + console.log(`📝 Activity logged: ${action} by ${username || 'system'}`); + } catch (error) { + // NEVER throw - logging should never crash the app + console.error('Failed to create activity log entry:', error); + } +} + +/** + * Query activity logs with filtering + * + * @param {Object} filters - Query filters + * @param {Date} filters.startDate - Start date filter + * @param {Date} filters.endDate - End date filter + * @param {string} filters.action - Specific action filter + * @param {string} filters.category - Category filter + * @param {string} filters.username - Username search (case-insensitive) + * @param {number} filters.userId - User ID filter + * @param {boolean} filters.success - Success filter + * @param {number} filters.limit - Results limit (max 500) + * @param {number} filters.offset - Pagination offset + * @returns {Promise} - Query results with total count + */ +async function queryLogs({ + startDate = null, + endDate = null, + action = null, + category = null, + username = null, + userId = null, + success = null, + limit = 100, + offset = 0, +}) { + try { + // Build where clause + const where = {}; + + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) where.createdAt.gte = new Date(startDate); + if (endDate) where.createdAt.lte = new Date(endDate); + } + + if (action) where.action = action; + if (category) where.category = category; + if (userId) where.userId = userId; + if (success !== null) where.success = success; + + // Username search (case-insensitive) + if (username) { + where.username = { + contains: username, + mode: 'insensitive', + }; + } + + // Cap limit at 500 + const safeLimit = Math.min(Math.max(1, limit), 500); + + // Execute query with total count + const [logs, total] = await Promise.all([ + prisma.activityLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: safeLimit, + skip: offset, + include: { + user: { + select: { + id: true, + username: true, + email: true, + avatar: true, + }, + }, + }, + }), + prisma.activityLog.count({ where }), + ]); + + return { + logs, + total, + limit: safeLimit, + offset, + hasMore: offset + logs.length < total, + }; + } catch (error) { + console.error('Failed to query activity logs:', error); + throw error; + } +} + +/** + * Get unique action types from logs + * @returns {Promise} - List of unique actions + */ +async function getActionTypes() { + try { + const results = await prisma.activityLog.findMany({ + select: { action: true, category: true }, + distinct: ['action'], + orderBy: { action: 'asc' }, + }); + + return results.map(r => ({ + action: r.action, + category: r.category, + })); + } catch (error) { + console.error('Failed to get action types:', error); + throw error; + } +} + +/** + * Get activity log statistics + * @returns {Promise} - Statistics object + */ +async function getStats() { + try { + const [ + totalLogs, + uniqueUsers, + failedActions, + logsByCategory, + recentActivity, + ] = await Promise.all([ + // Total logs + prisma.activityLog.count(), + + // Unique users + prisma.activityLog.findMany({ + select: { userId: true }, + distinct: ['userId'], + where: { userId: { not: null } }, + }).then(results => results.length), + + // Failed actions + prisma.activityLog.count({ + where: { success: false }, + }), + + // Logs by category + prisma.activityLog.groupBy({ + by: ['category'], + _count: { id: true }, + orderBy: { _count: { id: 'desc' } }, + }), + + // Recent activity (last 24h) + prisma.activityLog.count({ + where: { + createdAt: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + }, + }), + ]); + + return { + totalLogs, + uniqueUsers, + failedActions, + successRate: totalLogs > 0 ? ((totalLogs - failedActions) / totalLogs * 100).toFixed(2) : 100, + logsByCategory: logsByCategory.map(c => ({ + category: c.category, + count: c._count.id, + })), + recentActivity24h: recentActivity, + }; + } catch (error) { + console.error('Failed to get activity log stats:', error); + throw error; + } +} + +module.exports = { + ACTIONS, + log, + queryLogs, + getActionTypes, + getStats, +}; diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js new file mode 100644 index 0000000..09bf147 --- /dev/null +++ b/backend/src/utils/request.js @@ -0,0 +1,52 @@ +/** + * Request utility functions + * Helpers for extracting information from Express requests + */ + +/** + * Extract client IP address from request + * Considers proxy headers (X-Forwarded-For, X-Real-IP) + * + * @param {Object} req - Express request object + * @returns {string|null} - Client IP address or null + */ +function getClientIP(req) { + try { + // Check X-Forwarded-For header (used by proxies/load balancers) + const forwardedFor = req.headers['x-forwarded-for']; + if (forwardedFor) { + // X-Forwarded-For can contain multiple IPs, take the first one (client IP) + const ips = forwardedFor.split(',').map(ip => ip.trim()); + if (ips.length > 0 && ips[0]) { + return ips[0]; + } + } + + // Check X-Real-IP header (used by some proxies) + const realIP = req.headers['x-real-ip']; + if (realIP) { + return realIP; + } + + // Fallback to req.ip (Express built-in) + if (req.ip) { + // Express sometimes returns IPv6-mapped IPv4 (::ffff:192.168.1.1) + // Convert to standard IPv4 format + return req.ip.replace(/^::ffff:/, ''); + } + + // Fallback to socket remote address + if (req.connection && req.connection.remoteAddress) { + return req.connection.remoteAddress.replace(/^::ffff:/, ''); + } + + return null; + } catch (error) { + console.error('Error extracting client IP:', error); + return null; + } +} + +module.exports = { + getClientIP, +};