import type { FastifyInstance } from 'fastify'; import { userRepository } from '@core/storage/repositories/users.js'; import { channelRepository } from '@core/storage/repositories/channels.js'; import { logRepository } from '@core/storage/repositories/logs.js'; import { validateInput, profileUpdateSchema } from '@core/security/validation.js'; import { requireAuth, checkBanned } from '@core/auth/middleware.js'; import { hashPassword, verifyPassword } from '@core/auth/password.js'; import { destroyAllUserSessions, createUserSession } from '@core/auth/session.js'; import { config } from '@config/index.js'; import { getClientIP } from '@core/security/vpnDetect.js'; import { formatNumber, formatRelativeTime } from '@core/utils/helpers.js'; export async function userRoutes(app: FastifyInstance) { // User dashboard app.get('/user', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const user = await userRepository.findById(request.user!.id); if (!user) { return reply.status(404).view('pages/404.njk', { user: request.user }); } const channels = await channelRepository.findByOwner(user.id, { limit: 10 }); return reply.view('pages/user/dashboard.njk', { csrfToken: request.csrfToken(), user: request.user, profile: { ...user, formattedChannels: formatNumber(user.channelCount), memberSince: formatRelativeTime(user.createdAt), lastLogin: user.lastLoginAt ? formatRelativeTime(user.lastLoginAt) : 'Jamais', }, channels: channels.map(c => ({ ...c, formattedFollowers: formatNumber(c.followerCount), formattedVotes: formatNumber(c.voteCount), relativeTime: formatRelativeTime(c.createdAt), statusLabel: getStatusLabel(c.status), })), maxChannels: config.maxChannelsPerUser || 10, }); }); // Profile settings page app.get('/user/profile', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { return reply.view('pages/user/profile.njk', { csrfToken: request.csrfToken(), user: request.user, }); }); // Update profile app.post('/user/profile', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const validation = validateInput(profileUpdateSchema, request.body); if (!validation.success) { return reply.status(400).view('pages/user/profile.njk', { csrfToken: request.csrfToken(), user: request.user, errors: validation.errors.map(e => e.message), formData: request.body, }); } const { email, currentPassword, newPassword } = validation.data; const user = await userRepository.findById(request.user!.id); if (!user) { return reply.status(404).send({ error: 'User not found' }); } const updates: Record = {}; if (email !== undefined) { updates.email = email || null; } if (newPassword) { if (!currentPassword) { return reply.status(400).view('pages/user/profile.njk', { csrfToken: request.csrfToken(), user: request.user, errors: ['Mot de passe actuel requis'], formData: request.body, }); } const validPassword = await verifyPassword(user.passwordHash, currentPassword); if (!validPassword) { return reply.status(400).view('pages/user/profile.njk', { csrfToken: request.csrfToken(), user: request.user, errors: ['Mot de passe actuel incorrect'], formData: request.body, }); } updates.passwordHash = await hashPassword(newPassword); // Invalidate all other sessions await destroyAllUserSessions(user.id); const newSessionId = await createUserSession(user.id); reply.setCookie('session_id', newSessionId, { httpOnly: true, secure: config.NODE_ENV === 'production', sameSite: 'lax', maxAge: config.sessionTtlMs / 1000, path: '/', }); } if (Object.keys(updates).length > 0) { await userRepository.update(user.id, updates); await logRepository.log({ level: 'info', message: 'Profile updated', context: { fields: Object.keys(updates) }, userId: user.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); } return reply.view('pages/user/profile.njk', { csrfToken: request.csrfToken(), user: request.user, success: 'Profil mis à jour avec succès', }); }); // Channel stats for user app.get('/user/stats', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const channels = await channelRepository.findByOwner(request.user!.id, { limit: 1000 }); const stats = { totalChannels: channels.length, publishedChannels: channels.filter(c => c.status === 'published' && !c.isBanned).length, pendingChannels: channels.filter(c => c.status === 'pending' || c.status === 'scraping' || c.status === 'validating').length, rejectedChannels: channels.filter(c => c.status === 'rejected').length, bannedChannels: channels.filter(c => c.isBanned).length, totalFollowers: channels.reduce((sum, c) => sum + c.followerCount, 0), totalVotes: channels.reduce((sum, c) => sum + c.voteCount, 0), totalViews: channels.reduce((sum, c) => sum + c.viewCount, 0), topChannel: channels.reduce((max, c) => c.followerCount > (max?.followerCount || 0) ? c : max, null as any), }; return reply.view('pages/user/stats.njk', { csrfToken: request.csrfToken(), user: request.user, stats: { ...stats, formattedFollowers: formatNumber(stats.totalFollowers), formattedVotes: formatNumber(stats.totalVotes), formattedViews: formatNumber(stats.totalViews), }, channels: channels.slice(0, 10).map(c => ({ ...c, formattedFollowers: formatNumber(c.followerCount), formattedVotes: formatNumber(c.voteCount), })), }); }); // Delete account app.post('/user/delete-account', { preHandler: [requireAuth, checkBanned] }, async (request, reply) => { const { password } = request.body as { password?: string }; const user = await userRepository.findById(request.user!.id); if (!user) { return reply.status(404).send({ error: 'User not found' }); } if (!password) { return reply.status(400).send({ error: 'Mot de passe requis pour supprimer le compte' }); } const validPassword = await verifyPassword(user.passwordHash, password); if (!validPassword) { return reply.status(400).send({ error: 'Mot de passe incorrect' }); } // Delete user's channels const channels = await channelRepository.findByOwner(user.id, { limit: 1000 }); for (const channel of channels) { await channelRepository.delete(channel.id); } // Delete user await userRepository.delete(user.id); await destroyAllUserSessions(user.id); await logRepository.log({ level: 'warn', message: 'Account deleted by user', context: { username: user.username }, userId: user.id, ip: getClientIP(request), userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); reply.clearCookie('session_id', { path: '/' }); return reply.redirect('/auth/login?deleted=1'); }); } 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' }; }