/** * Serveur web pour héberger les transcripts avec authentification Discord * Interface professionnelle avec dashboard et statistiques */ require('dotenv').config(); const express = require('express'); const session = require('express-session'); const passport = require('passport'); const path = require('path'); const fs = require('fs'); const db = require('../functions/database/db.js'); // Note: passport-discord peut nécessiter une installation séparée let DiscordStrategy; try { DiscordStrategy = require('passport-discord').Strategy; } catch (err) { console.warn('⚠️ passport-discord non installé. Installer avec: npm install passport-discord'); DiscordStrategy = null; } const app = express(); const PORT = process.env.TRANSCRIPT_PORT || 3000; // Middleware pour parser les données app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Configuration de la session app.use(session({ secret: process.env.SESSION_SECRET || 'change-me-in-production', resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 // 24 heures } })); // Configuration Passport app.use(passport.initialize()); app.use(passport.session()); // Variable pour savoir si la stratégie Discord est configurée let discordStrategyConfigured = false; // Récupérer le Client ID et Secret (avec fallback pour compatibilité) const discordClientId = process.env.DISCORD_CLIENT_ID || process.env.CLIENT_ID; const discordClientSecret = process.env.DISCORD_CLIENT_SECRET || process.env.CLIENT_SECRET; // Configuration Discord OAuth if (DiscordStrategy && discordClientId && discordClientSecret) { try { passport.use('discord', new DiscordStrategy({ clientID: discordClientId, clientSecret: discordClientSecret, callbackURL: process.env.DISCORD_CALLBACK_URL || `http://localhost:${PORT}/auth/discord/callback`, scope: ['identify', 'guilds'] }, (accessToken, refreshToken, profile, done) => { return done(null, profile); })); discordStrategyConfigured = true; console.log('✅ Stratégie Discord OAuth configurée avec succès'); } catch (err) { console.error('❌ Erreur lors de la configuration de la stratégie Discord:', err); discordStrategyConfigured = false; } } else { if (!DiscordStrategy) { console.warn('⚠️ passport-discord non installé. Installer avec: npm install passport-discord'); } else { if (!discordClientId) { console.warn('⚠️ DISCORD_CLIENT_ID ou CLIENT_ID manquant dans .env'); } if (!discordClientSecret) { console.warn('⚠️ DISCORD_CLIENT_SECRET ou CLIENT_SECRET manquant dans .env'); } console.warn('⚠️ Discord OAuth non configuré. Configurer DISCORD_CLIENT_SECRET (ou CLIENT_SECRET) et DISCORD_CLIENT_ID (ou CLIENT_ID) dans .env'); } discordStrategyConfigured = false; } passport.serializeUser((user, done) => { done(null, user); }); passport.deserializeUser((obj, done) => { done(null, obj); }); // Middleware pour vérifier l'authentification function isAuthenticated(req, res, next) { if (req.isAuthenticated()) { return next(); } res.redirect('/login'); } // Middleware pour vérifier les permissions async function hasPermission(req, res, next) { if (!req.isAuthenticated()) { return res.redirect('/login'); } const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html (sans le préfixe /tickets/ ou /transcripts/) // Le chemin dans la DB est transcripts/type/date/file.html ou tickets/type/date/file.html (ancien format) // On cherche avec les deux formats pour compatibilité const transcriptsPath = `transcripts/${filePath}`; const ticketsPath = `tickets/${filePath}`; try { // Chercher avec les deux formats (nouveau: transcripts/, ancien: tickets/) const [tickets] = await db.query( 'SELECT * FROM tickets WHERE transcriptPath = ? OR transcriptPath = ? OR transcriptPath LIKE ?', [transcriptsPath, ticketsPath, `%${filePath}`] ); if (tickets.length === 0) { return res.status(404).send('Transcript introuvable.'); } const ticket = tickets[0]; const userId = req.user.id; if (ticket.userId === userId) { return next(); } // Vérifier si l'utilisateur est modérateur // Pour l'instant, on vérifie si l'utilisateur est dans la liste des modérateurs configurés // TODO: Implémenter une vérification Discord réelle (rôles, permissions) const moderatorIds = process.env.MODERATOR_IDS ? process.env.MODERATOR_IDS.split(',') : []; if (moderatorIds.includes(userId)) { return next(); } // Sinon, accès refusé return res.status(403).send('Accès refusé. Vous n\'avez pas la permission d\'accéder à ce transcript.'); } catch (err) { console.error('Erreur lors de la vérification des permissions:', err); return res.status(500).send('Erreur serveur.'); } } // Fonction pour générer le HTML de la page function generateDashboardHTML(user, stats, userTickets, allTickets) { // Gérer les nouveaux et anciens formats de Discord (discriminator peut être null ou '0') let userAvatar; if (user.avatar) { userAvatar = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`; } else if (user.discriminator && user.discriminator !== '0') { userAvatar = `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`; } else { // Pour les nouveaux comptes sans discriminator, utiliser l'ID mod 5 userAvatar = `https://cdn.discordapp.com/embed/avatars/${parseInt(user.id) % 5}.png`; } return ` Dashboard - Transcripts France Femboy
📊
Tickets Totaux
${stats.totalTickets}
📝
Mes Tickets
${stats.myTickets}
🔓
Tickets Ouverts
${stats.openTickets}
🔒
Tickets Fermés
${stats.closedTickets}

📄 Mes Tickets

${userTickets.length > 0 ? `
${userTickets.map(ticket => generateTicketCard(ticket)).join('')}
` : '
Aucun ticket réclamé.
'}

🌐 Tous les Transcripts

Voir tous les transcripts sur la page dédiée.

${allTickets.length > 0 ? `
${allTickets.slice(0, 6).map(ticket => generateTicketCard(ticket)).join('')}
${allTickets.length > 6 ? `

Voir tous les transcripts (${allTickets.length})

` : ''} ` : '
Aucun transcript disponible.
'}
`; } // Fonction pour formater la date selon le fuseau horaire (France par défaut) // Format: "9/11/2025, 18h43" (format français court avec heure) function formatDate(timestamp, timezone = 'Europe/Paris') { try { const date = new Date(timestamp); // Utiliser Intl.DateTimeFormat pour gérer correctement les fuseaux horaires const formatter = new Intl.DateTimeFormat('fr-FR', { timeZone: timezone, year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }); // Formater la date const parts = formatter.formatToParts(date); const day = parts.find(p => p.type === 'day').value; const month = parts.find(p => p.type === 'month').value; const year = parts.find(p => p.type === 'year').value; const hour = parts.find(p => p.type === 'hour').value; const minute = parts.find(p => p.type === 'minute').value; return `${day}/${month}/${year}, ${hour}h${minute}`; } catch (err) { // Fallback sur le format français standard const date = new Date(timestamp); const day = date.getDate(); const month = date.getMonth() + 1; const year = date.getFullYear(); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${day}/${month}/${year}, ${hours}h${minutes}`; } } function generateTicketCard(ticket) { const date = formatDate(ticket.createdAt); const url = ticket.transcriptPath ? `/${ticket.transcriptPath}` : '#'; const typeClass = `type-${ticket.type.toLowerCase().replace(/\s+/g, '-')}`; const statusClass = `status-${ticket.status.toLowerCase()}`; const typeEmojis = { 'Support': '💬', 'Plainte': '📢', 'Plainte Staff': '⚠️', 'Candidature': '📝', 'Problème Technique': '🔧' }; return `
${typeEmojis[ticket.type] || '🎫'} ${ticket.ticketId}
${ticket.type}
Créé par: ${escapeHtml(ticket.userTag)}
${ticket.claimedBy ? `
Géré par: ${ticket.claimedByTag || 'Modérateur'}
` : ''}
Date: ${date}
${ticket.status}
${ticket.transcriptPath ? `Voir le transcript` : 'Transcript non disponible'}
`; } function escapeHtml(text) { if (!text) return ''; const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, m => map[m]); } // Routes app.get('/login', (req, res) => { res.send(` Connexion - Transcripts

🔐 Connexion requise

Pour accéder aux transcripts, vous devez vous connecter avec Discord.

Se connecter avec Discord
`); }); app.get('/auth/discord', (req, res, next) => { if (!discordStrategyConfigured) { return res.send(` Erreur de Configuration

⚠️ Discord OAuth non configuré

Pour utiliser l'authentification Discord, vous devez :

  1. Installer passport-discord : npm install passport-discord
  2. Configurer DISCORD_CLIENT_ID et DISCORD_CLIENT_SECRET dans .env
  3. Configurer DISCORD_CALLBACK_URL dans .env

Voir server/README.md pour plus d'informations.

`); } passport.authenticate('discord')(req, res, next); }); app.get('/auth/discord/callback', (req, res, next) => { if (!discordStrategyConfigured) { return res.redirect('/login?error=not_configured'); } passport.authenticate('discord', { failureRedirect: '/login?error=auth_failed' })(req, res, next); }, (req, res) => { res.redirect('/'); } ); app.get('/auth/logout', (req, res) => { req.logout(() => { res.redirect('/login'); }); }); // Servir les fichiers statiques (CSS, JS, images, etc.) // IMPORTANT: Cette route doit être AVANT les routes avec wildcards app.use('/static', express.static(path.join(__dirname, 'public'))); // Servir les fichiers de transcripts // Support à la fois /tickets/* et /transcripts/* pour compatibilité app.get('/tickets/*', isAuthenticated, hasPermission, (req, res) => { const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html // Les fichiers sont toujours dans transcripts/, même si l'URL est /tickets/ const fullPath = path.join(process.cwd(), 'transcripts', filePath); if (!fs.existsSync(fullPath)) { return res.status(404).send('Transcript introuvable.'); } res.sendFile(fullPath); }); app.get('/transcripts/*', isAuthenticated, hasPermission, (req, res) => { const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html // Les fichiers sont dans transcripts/ const fullPath = path.join(process.cwd(), 'transcripts', filePath); if (!fs.existsSync(fullPath)) { return res.status(404).send('Transcript introuvable.'); } res.sendFile(fullPath); }); // Page d'accueil (Dashboard) app.get('/', isAuthenticated, async (req, res) => { try { const userId = req.user.id; // Récupérer les statistiques const [totalStats] = await db.query('SELECT COUNT(*) as total FROM tickets'); const [myTicketsStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE claimedBy = ?', [userId]); const [openStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE status = ?', ['Ouvert']); const [closedStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE status = ?', ['Fermé']); const stats = { totalTickets: totalStats[0].total, myTickets: myTicketsStats[0].total, openTickets: openStats[0].total, closedTickets: closedStats[0].total }; // Récupérer les tickets claimés par l'utilisateur (pour "Mes Tickets") const [userTickets] = await db.query( 'SELECT * FROM tickets WHERE claimedBy = ? AND transcriptPath IS NOT NULL ORDER BY createdAt DESC LIMIT 20', [userId] ); // Récupérer tous les transcripts accessibles (avec transcriptPath) pour la page dédiée const [allTickets] = await db.query( 'SELECT * FROM tickets WHERE transcriptPath IS NOT NULL ORDER BY createdAt DESC', [] ); const html = generateDashboardHTML(req.user, stats, userTickets, allTickets); res.send(html); } catch (err) { console.error('Erreur lors de la génération du dashboard:', err); res.status(500).send('Erreur serveur.'); } }); // Page dédiée pour tous les transcripts (tableau) app.get('/transcripts', isAuthenticated, async (req, res) => { try { const userId = req.user.id; // Récupérer tous les transcripts avec les informations des modérateurs const [allTickets] = await db.query( `SELECT * FROM tickets WHERE transcriptPath IS NOT NULL ORDER BY createdAt DESC`, [] ); // Récupérer les informations des modérateurs qui ont claim les tickets // Note: On pourrait utiliser l'API Discord pour récupérer les vrais noms, mais pour l'instant on utilise les tags stockés // Pour améliorer, on pourrait créer une table de cache des utilisateurs Discord const html = generateTranscriptsPageHTML(req.user, allTickets); res.send(html); } catch (err) { console.error('Erreur lors de la génération de la page transcripts:', err); res.status(500).send('Erreur serveur.'); } }); // Fonction pour générer la page HTML des transcripts avec tableau function generateTranscriptsPageHTML(user, tickets) { // Gérer les nouveaux et anciens formats de Discord (discriminator peut être null ou '0') let userAvatar; if (user.avatar) { userAvatar = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`; } else if (user.discriminator && user.discriminator !== '0') { userAvatar = `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`; } else { userAvatar = `https://cdn.discordapp.com/embed/avatars/${parseInt(user.id) % 5}.png`; } const typeEmojis = { 'Support': '💬', 'Plainte': '📢', 'Plainte Staff': '⚠️', 'Candidature': '📝', 'Problème Technique': '🔧' }; const statusColors = { 'Ouvert': '#10b981', 'Fermé': '#ef4444', 'En attente': '#f59e0b', 'Supprimé': '#6b7280' }; return ` Tous les Transcripts - France Femboy

📋 Tous les Transcripts

${tickets.length > 0 ? `
${tickets.map(ticket => { const typeClass = `type-${ticket.type.toLowerCase().replace(/\s+/g, '-')}`; const statusColor = statusColors[ticket.status] || '#6b7280'; const date = formatDate(ticket.createdAt); const url = ticket.transcriptPath ? `/${ticket.transcriptPath}` : '#'; // Récupérer le nom du modérateur qui a claim le ticket let moderatorDisplay = 'Non assigné'; if (ticket.claimedBy && ticket.claimedByTag) { moderatorDisplay = `Géré par: ${escapeHtml(ticket.claimedByTag)}`; } else if (ticket.claimedBy) { moderatorDisplay = 'Géré par: Modérateur'; } return ` `; }).join('')}
Ticket Type Modérateur Date Status Action
${typeEmojis[ticket.type] || '🎫'} ${ticket.ticketId} ${ticket.type} ${moderatorDisplay} ${date} ${ticket.status} ${ticket.transcriptPath ? `Voir` : 'N/A'}
` : '
Aucun transcript disponible.
'}
`; } // Démarrer le serveur app.listen(PORT, () => { console.log(`🌐 Serveur de transcripts démarré sur le port ${PORT}`); console.log(`🔗 URL: http://localhost:${PORT}`); console.log(`📝 Login: http://localhost:${PORT}/login`); });