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