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 { 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<string, unknown> = {}; | |
| 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<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' }; | |
| } |