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
This commit is contained in:
73
backend/src/middleware/admin.js
Normal file
73
backend/src/middleware/admin.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
322
backend/src/services/activityLog.js
Normal file
322
backend/src/services/activityLog.js
Normal file
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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<Object>} - 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<Array>} - 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<Object>} - 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,
|
||||||
|
};
|
||||||
52
backend/src/utils/request.js
Normal file
52
backend/src/utils/request.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user