Chat-With-AI / src /modules /user /routes.ts
NathMen12's picture
Upload 56 files
ef73937 verified
Raw
History Blame Contribute Delete
8.11 kB
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' };
}