import type { FastifyInstance } from 'fastify'; import { userRepository } from '@core/storage/repositories/users.js'; import { channelRepository } from '@core/storage/repositories/channels.js'; import { voteRepository } from '@core/storage/repositories/votes.js'; import { banRepository } from '@core/storage/repositories/bans.js'; import { logRepository } from '@core/storage/repositories/logs.js'; import { scrapeQueueRepository } from '@core/storage/repositories/scrapeQueue.js'; import { settingsRepository } from '@core/storage/repositories/settings.js'; import { validateInput, adminUserSearchSchema, adminChannelSearchSchema, banSchema } from '@core/security/validation.js'; import { requireAdmin } from '@core/auth/middleware.js'; import { config } from '@config/index.js'; import { getClientIP } from '@core/security/vpnDetect.js'; import { formatNumber, formatRelativeTime, formatDate } from '@core/utils/helpers.js'; export async function adminRoutes(app: FastifyInstance) { // Admin middleware for all routes app.addHook('preHandler', requireAdmin); // Dashboard app.get('/admin', async (request, reply) => { const [ userStats, channelStats, voteStats, banStats, logStats, queueStats, recentLogs, recentUsers, recentChannels, ] = await Promise.all([ getUserStats(), channelRepository.getStats(), voteRepository.getVoteStats(), banRepository.getBanStats(), logRepository.getLogStats(), scrapeQueueRepository.getQueueStats(), logRepository.getLogs({ limit: 20 }), userRepository.findAll({ limit: 10, sortBy: 'createdAt', sortOrder: 'desc' }), channelRepository.findAll({ limit: 10, sortBy: 'createdAt', sortOrder: 'desc' }), ]); // Calculate visits (from logs) const visits = recentLogs.filter(l => l.url === '/' || l.url === '').length; return reply.view('pages/admin/dashboard.njk', { csrfToken: request.csrfToken(), user: request.user, stats: { users: userStats, channels: channelStats, votes: voteStats, bans: banStats, logs: logStats, queue: queueStats, visits, }, recentLogs: recentLogs.map(l => ({ ...l, relativeTime: formatRelativeTime(l.createdAt), })), recentUsers: recentUsers.map(u => ({ ...u, relativeTime: formatRelativeTime(u.createdAt), })), recentChannels: recentChannels.map(c => ({ ...c, formattedFollowers: formatNumber(c.followerCount), relativeTime: formatRelativeTime(c.createdAt), })), }); }); // Users management app.get('/admin/users', async (request, reply) => { const validation = validateInput(adminUserSearchSchema, request.query); const params = validation.success ? validation.data : { page: 1, limit: 20 }; const page = params.page; const limit = params.limit; const offset = (page - 1) * limit; const filter: Record = {}; if (params.q) { // Search in username } if (params.isAdmin !== undefined) filter.isAdmin = params.isAdmin; if (params.isBanned !== undefined) filter.isBanned = params.isBanned; const [users, total] = await Promise.all([ userRepository.findAll({ filter, limit, offset, sortBy: 'createdAt', sortOrder: 'desc' }), userRepository.count(filter), ]); return reply.view('pages/admin/users.njk', { csrfToken: request.csrfToken(), user: request.user, users: users.map(u => ({ ...u, relativeTime: formatRelativeTime(u.createdAt), lastLogin: u.lastLoginAt ? formatRelativeTime(u.lastLoginAt) : 'Jamais', })), pagination: { page, totalPages: Math.ceil(total / limit), total, hasNext: page * limit < total, hasPrev: page > 1, }, filters: params, }); }); app.post('/admin/users/:id/ban', async (request, reply) => { const validation = validateInput(banSchema, { ...request.body, type: 'user', targetId: request.params.id }); if (!validation.success) { return reply.status(400).send({ errors: validation.errors.map(e => e.message) }); } const targetUser = await userRepository.findById(request.params.id); if (!targetUser) { return reply.status(404).send({ error: 'User not found' }); } if (targetUser.isAdmin) { return reply.status(403).send({ error: 'Cannot ban admin user' }); } const expiresAt = validation.data.expiresAt ? new Date(validation.data.expiresAt) : undefined; await banRepository.banUser(targetUser.id, targetUser.username, validation.data.reason, request.user!.id, expiresAt); await userRepository.banUser(targetUser.id, validation.data.reason); await logRepository.log({ level: 'warn', message: 'User banned by admin', context: { targetUserId: targetUser.id, reason: validation.data.reason, expiresAt }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); app.post('/admin/users/:id/unban', async (request, reply) => { const targetUser = await userRepository.findById(request.params.id); if (!targetUser) { return reply.status(404).send({ error: 'User not found' }); } await userRepository.unbanUser(targetUser.id); await banRepository.unban(targetUser.id); // Note: this needs banId, will need adjustment await logRepository.log({ level: 'info', message: 'User unbanned by admin', context: { targetUserId: targetUser.id }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); app.post('/admin/users/:id/promote', async (request, reply) => { const targetUser = await userRepository.findById(request.params.id); if (!targetUser) { return reply.status(404).send({ error: 'User not found' }); } await userRepository.promoteToAdmin(targetUser.id); await logRepository.log({ level: 'info', message: 'User promoted to admin', context: { targetUserId: targetUser.id }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); app.post('/admin/users/:id/demote', async (request, reply) => { const targetUser = await userRepository.findById(request.params.id); if (!targetUser) { return reply.status(404).send({ error: 'User not found' }); } if (targetUser.id === request.user!.id) { return reply.status(403).send({ error: 'Cannot demote yourself' }); } await userRepository.demoteFromAdmin(targetUser.id); await logRepository.log({ level: 'info', message: 'User demoted from admin', context: { targetUserId: targetUser.id }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); // Channels management app.get('/admin/channels', async (request, reply) => { const validation = validateInput(adminChannelSearchSchema, request.query); const params = validation.success ? validation.data : { page: 1, limit: 20 }; const page = params.page; const limit = params.limit; const offset = (page - 1) * limit; const filter: Record = {}; if (params.q) { // Search handled in repository } if (params.status) filter.status = params.status; if (params.isBanned !== undefined) filter.isBanned = params.isBanned; const [channels, total] = await Promise.all([ channelRepository.findAll({ filter, limit, offset, sortBy: 'createdAt', sortOrder: 'desc' }), channelRepository.count(filter), ]); return reply.view('pages/admin/channels.njk', { csrfToken: request.csrfToken(), user: request.user, channels: channels.map(c => ({ ...c, formattedFollowers: formatNumber(c.followerCount), formattedVotes: formatNumber(c.voteCount), relativeTime: formatRelativeTime(c.createdAt), statusLabel: getStatusLabel(c.status), })), pagination: { page, totalPages: Math.ceil(total / limit), total, hasNext: page * limit < total, hasPrev: page > 1, }, filters: params, statusOptions: ['pending', 'scraping', 'validating', 'published', 'rejected', 'banned'], }); }); app.post('/admin/channels/:id/delete', async (request, reply) => { const channel = await channelRepository.findById(request.params.id); if (!channel) { return reply.status(404).send({ error: 'Channel not found' }); } await channelRepository.delete(channel.id); await userRepository.decrementChannelCount(channel.ownerId); await logRepository.log({ level: 'warn', message: 'Channel deleted by admin', context: { channelId: channel.id, channelName: channel.name }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); app.post('/admin/channels/:id/ban', async (request, reply) => { const validation = validateInput(banSchema, { ...request.body, type: 'channel', targetId: request.params.id }); if (!validation.success) { return reply.status(400).send({ errors: validation.errors.map(e => e.message) }); } const channel = await channelRepository.findById(request.params.id); if (!channel) { return reply.status(404).send({ error: 'Channel not found' }); } const expiresAt = validation.data.expiresAt ? new Date(validation.data.expiresAt) : undefined; await banRepository.banChannel(channel.id, channel.name, validation.data.reason, request.user!.id, expiresAt); await channelRepository.ban(channel.id, validation.data.reason); await logRepository.log({ level: 'warn', message: 'Channel banned by admin', context: { channelId: channel.id, reason: validation.data.reason, expiresAt }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); app.post('/admin/channels/:id/unban', async (request, reply) => { const channel = await channelRepository.findById(request.params.id); if (!channel) { return reply.status(404).send({ error: 'Channel not found' }); } await channelRepository.unban(channel.id); // Note: need to unban in banRepository too await logRepository.log({ level: 'info', message: 'Channel unbanned by admin', context: { channelId: channel.id }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); // Bans management app.get('/admin/bans', async (request, reply) => { const [userBans, channelBans, ipBans] = await Promise.all([ banRepository.getActiveBans('user'), banRepository.getActiveBans('channel'), banRepository.getActiveBans('ip'), ]); return reply.view('pages/admin/bans.njk', { csrfToken: request.csrfToken(), user: request.user, userBans: userBans.map(b => ({ ...b, relativeTime: formatRelativeTime(b.createdAt) })), channelBans: channelBans.map(b => ({ ...b, relativeTime: formatRelativeTime(b.createdAt) })), ipBans: ipBans.map(b => ({ ...b, relativeTime: formatRelativeTime(b.createdAt) })), }); }); app.post('/admin/bans/ip', async (request, reply) => { const validation = validateInput(banSchema, { ...request.body, type: 'ip' }); if (!validation.success) { return reply.status(400).send({ errors: validation.errors.map(e => e.message) }); } const expiresAt = validation.data.expiresAt ? new Date(validation.data.expiresAt) : undefined; await banRepository.banIp(validation.data.targetValue, validation.data.reason, request.user!.id, expiresAt); await logRepository.log({ level: 'warn', message: 'IP banned by admin', context: { ip: validation.data.targetValue, reason: validation.data.reason, expiresAt }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); app.post('/admin/bans/:id/unban', async (request, reply) => { await banRepository.unban(request.params.id); await logRepository.log({ level: 'info', message: 'Ban removed by admin', context: { banId: request.params.id }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); // Logs app.get('/admin/logs', async (request, reply) => { const page = parseInt(request.query.page as string) || 1; const limit = 50; const offset = (page - 1) * limit; const level = request.query.level as string; const startDate = request.query.startDate as string; const endDate = request.query.endDate as string; const logs = await logRepository.getLogs({ level: level as any, startDate, endDate, limit, offset }); const stats = await logRepository.getLogStats(); return reply.view('pages/admin/logs.njk', { csrfToken: request.csrfToken(), user: request.user, logs: logs.map(l => ({ ...l, relativeTime: formatRelativeTime(l.createdAt) })), stats, pagination: { page, totalPages: Math.ceil(stats.total / limit), }, filters: { level, startDate, endDate }, levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'], }); }); app.post('/admin/logs/cleanup', async (request, reply) => { const days = parseInt(request.query.days as string) || 30; const deleted = await logRepository.cleanupOldLogs(days); await logRepository.log({ level: 'info', message: 'Old logs cleaned up', context: { deletedCount: deleted, daysKept: days }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true, deleted }); }); // Queue monitoring app.get('/admin/queues', async (request, reply) => { const [scrapeStats, updateStats, pendingItems, processingItems, failedItems] = await Promise.all([ scrapeQueueRepository.getQueueStats(), Promise.resolve({ pending: 0, processing: 0, completed: 0, failed: 0 }), // update queue stats scrapeQueueRepository.findAll({ filter: { status: 'pending' }, limit: 20, sortBy: 'createdAt', sortOrder: 'asc' }), scrapeQueueRepository.findAll({ filter: { status: 'processing' }, limit: 20, sortBy: 'updatedAt', sortOrder: 'asc' }), scrapeQueueRepository.findAll({ filter: { status: 'failed' }, limit: 20, sortBy: 'updatedAt', sortOrder: 'desc' }), ]); return reply.view('pages/admin/queues.njk', { csrfToken: request.csrfToken(), user: request.user, scrapeStats, pendingItems: pendingItems.map(i => ({ ...i, relativeTime: formatRelativeTime(i.createdAt) })), processingItems: processingItems.map(i => ({ ...i, relativeTime: formatRelativeTime(i.updatedAt) })), failedItems: failedItems.map(i => ({ ...i, relativeTime: formatRelativeTime(i.updatedAt) })), }); }); app.post('/admin/queues/scrape/:id/retry', async (request, reply) => { await scrapeQueueRepository.updateStatus(request.params.id, 'pending', { attempts: 0, error: undefined }); return reply.send({ success: true }); }); app.post('/admin/queues/scrape/cleanup', async (request, reply) => { const days = parseInt(request.query.days as string) || 7; const deleted = await scrapeQueueRepository.cleanupOldItems(days); return reply.send({ success: true, deleted }); }); // Settings app.get('/admin/settings', async (request, reply) => { const settings = await settingsRepository.getAll(); return reply.view('pages/admin/settings.njk', { csrfToken: request.csrfToken(), user: request.user, settings, }); }); app.post('/admin/settings', async (request, reply) => { const { key, value, description, isPublic } = request.body as { key: string; value: unknown; description?: string; isPublic?: boolean }; await settingsRepository.set(key, value, description, isPublic); await logRepository.log({ level: 'info', message: 'Setting updated', context: { key, value }, userId: request.user!.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); return reply.send({ success: true }); }); // Error pages app.get('/admin/errors', async (request, reply) => { const page = parseInt(request.query.page as string) || 1; const limit = 50; const offset = (page - 1) * limit; const errorLogs = await logRepository.getErrorLogs(limit + offset); const paginated = errorLogs.slice(offset, offset + limit); return reply.view('pages/admin/errors.njk', { csrfToken: request.csrfToken(), user: request.user, errors: paginated.map(l => ({ ...l, relativeTime: formatRelativeTime(l.createdAt) })), pagination: { page, totalPages: Math.ceil(errorLogs.length / limit), }, }); }); } async function getUserStats() { const all = await userRepository.findAll({ limit: 10000 }); const now = new Date(); const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); return { total: all.length, admins: all.filter(u => u.isAdmin).length, banned: all.filter(u => u.isBanned).length, active24h: all.filter(u => u.lastLoginAt && u.lastLoginAt > dayAgo).length, active7d: all.filter(u => u.lastLoginAt && u.lastLoginAt > weekAgo).length, new24h: all.filter(u => u.createdAt > dayAgo).length, new7d: all.filter(u => u.createdAt > weekAgo).length, }; } function getStatusLabel(status: string): { label: string; class: string } { const labels: Record = { pending: { label: 'En attente', class: 'badge-warning' }, scraping: { label: 'Scraping', class: 'badge-info' }, validating: { label: 'Vérification', class: 'badge-info' }, published: { label: 'Publié', class: 'badge-success' }, rejected: { label: 'Rejeté', class: 'badge-danger' }, banned: { label: 'Banni', class: 'badge-dark' }, }; return labels[status] || { label: status, class: 'badge-secondary' }; }