Spaces:
Build error
Build error
| 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<string, unknown> = {}; | |
| 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<string, unknown> = {}; | |
| 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<string, { label: string; class: string }> = { | |
| 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' }; | |
| } |