- Add intelligent content-game compatibility system with visual badges - Fix Adventure Reader to work with Dragon's Pearl content structure - Implement multi-column games grid for faster navigation - Add pronunciation display for Chinese vocabulary and sentences - Fix navigation breadcrumb to show proper hierarchy (Home > Levels > Content) - Add back buttons to all navigation pages - Improve JSONContentLoader to preserve story structure - Add comprehensive debugging and diagnostic tools 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// Serveur WebSocket simple pour recevoir les logs temps réel
|
|
|
|
const WebSocket = require('ws');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const pino = require('pino');
|
|
const http = require('http');
|
|
const url = require('url');
|
|
const crypto = require('crypto');
|
|
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
|
|
|
const wsPort = 8082;
|
|
const httpPort = 8083;
|
|
|
|
// Configuration du logger Pino avec fichier daté
|
|
const now = new Date();
|
|
const timestamp = now.toISOString().slice(0, 10) + '_' +
|
|
now.toLocaleTimeString('fr-FR').replace(/:/g, '-');
|
|
const logFile = path.join(__dirname, '..', 'logs', `class-generator-${timestamp}.log`);
|
|
|
|
// Créer le dossier logs s'il n'existe pas
|
|
const logsDir = path.join(__dirname, '..', 'logs');
|
|
if (!fs.existsSync(logsDir)) {
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
console.log('📁 Dossier logs créé');
|
|
}
|
|
|
|
// Logger Pino avec sortie fichier et console
|
|
const logger = pino(
|
|
{
|
|
level: 'debug',
|
|
timestamp: pino.stdTimeFunctions.isoTime,
|
|
},
|
|
pino.multistream([
|
|
{ stream: pino.destination({ dest: logFile, mkdir: true, sync: false }) },
|
|
{ stream: pino.transport({ target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } }) }
|
|
])
|
|
);
|
|
|
|
// Configuration DigitalOcean
|
|
const DO_CONFIG = {
|
|
ACCESS_KEY: 'DO8018LC8QF7CFBF7E2K',
|
|
SECRET_KEY: 'RLH4bUidH4zb1XQAtBUeUnA4vjizdkQ78D1fOZ5gYpk',
|
|
REGION: 'fra1',
|
|
ENDPOINT: 'https://autocollant.fra1.digitaloceanspaces.com',
|
|
BUCKET_ENDPOINT: 'https://fra1.digitaloceanspaces.com',
|
|
BUCKET: 'autocollant',
|
|
CONTENT_PATH: 'Class_generator/ContentMe'
|
|
};
|
|
|
|
// Fonctions AWS Signature
|
|
function sha256(message) {
|
|
return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
|
|
}
|
|
|
|
function hmacSha256(key, message) {
|
|
return crypto.createHmac('sha256', key).update(message, 'utf8').digest();
|
|
}
|
|
|
|
async function generateAWSSignature(method, targetUrl) {
|
|
const now = new Date();
|
|
const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
const timeStamp = now.toISOString().slice(0, 19).replace(/[-:]/g, '') + 'Z';
|
|
|
|
const urlObj = new URL(targetUrl);
|
|
const host = urlObj.hostname;
|
|
const canonicalUri = urlObj.pathname;
|
|
const canonicalQueryString = urlObj.search ? urlObj.search.slice(1) : '';
|
|
|
|
const canonicalHeaders = `host:${host}\nx-amz-date:${timeStamp}\n`;
|
|
const signedHeaders = 'host;x-amz-date';
|
|
|
|
const payloadHash = method === 'GET' ? sha256('') : 'UNSIGNED-PAYLOAD';
|
|
const canonicalRequest = [
|
|
method,
|
|
canonicalUri,
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash
|
|
].join('\n');
|
|
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
const credentialScope = `${dateStamp}/${DO_CONFIG.REGION}/s3/aws4_request`;
|
|
const canonicalRequestHash = sha256(canonicalRequest);
|
|
const stringToSign = [
|
|
algorithm,
|
|
timeStamp,
|
|
credentialScope,
|
|
canonicalRequestHash
|
|
].join('\n');
|
|
|
|
const kDate = hmacSha256('AWS4' + DO_CONFIG.SECRET_KEY, dateStamp);
|
|
const kRegion = hmacSha256(kDate, DO_CONFIG.REGION);
|
|
const kService = hmacSha256(kRegion, 's3');
|
|
const kSigning = hmacSha256(kService, 'aws4_request');
|
|
const signature = hmacSha256(kSigning, stringToSign).toString('hex');
|
|
|
|
const authorization = `${algorithm} Credential=${DO_CONFIG.ACCESS_KEY}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
|
|
return {
|
|
'Authorization': authorization,
|
|
'X-Amz-Date': timeStamp,
|
|
'X-Amz-Content-Sha256': payloadHash
|
|
};
|
|
}
|
|
|
|
// Serveur HTTP proxy pour DigitalOcean
|
|
const httpServer = http.createServer(async (req, res) => {
|
|
// CORS headers
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/do-proxy/_list') {
|
|
// Route spéciale pour lister tous les fichiers dans le dossier
|
|
logger.info(`📂 DÉBUT - Listing du dossier ContentMe`);
|
|
|
|
try {
|
|
// Utiliser l'endpoint du bucket directement
|
|
const listUrl = `${DO_CONFIG.BUCKET_ENDPOINT}/${DO_CONFIG.BUCKET}?list-type=2&prefix=${DO_CONFIG.CONTENT_PATH}/`;
|
|
logger.info(`🌐 List URL: ${listUrl}`);
|
|
|
|
const headers = await generateAWSSignature('GET', listUrl);
|
|
logger.info(`📋 Headers générés pour listing: ${JSON.stringify(headers, null, 2)}`);
|
|
|
|
const response = await fetch(listUrl, {
|
|
method: 'GET',
|
|
headers: headers
|
|
});
|
|
|
|
if (response.ok) {
|
|
const xmlText = await response.text();
|
|
logger.info(`✅ Listing reçu: ${xmlText.length} caractères`);
|
|
|
|
// Parser le XML pour extraire les noms de fichiers
|
|
const fileList = parseS3ListResponse(xmlText);
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Access-Control-Allow-Origin': '*'
|
|
});
|
|
res.end(JSON.stringify({ files: fileList }));
|
|
logger.info(`📁 ${fileList.length} fichiers listés`);
|
|
} else {
|
|
const errorText = await response.text();
|
|
logger.error(`❌ Erreur listing: ${response.status} - ${errorText}`);
|
|
|
|
// Si c'est un 403, essayer une approche différente
|
|
if (response.status === 403) {
|
|
logger.info(`🔄 Tentative avec URL alternative...`);
|
|
// Pour l'instant on retourne l'erreur
|
|
res.writeHead(403, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
res.end(JSON.stringify({
|
|
error: 'ListBucket permission denied',
|
|
message: 'Les clés actuelles ne permettent pas le listing. Utilisation de la liste connue.',
|
|
knownFiles: ['english-class-demo.json', 'sbs-level-7-8-new.json']
|
|
}));
|
|
} else {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
res.end('Error listing files');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`💥 Exception listing: ${error.message}`);
|
|
res.writeHead(500, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
res.end(`Error: ${error.message}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((req.method === 'GET' || req.method === 'HEAD') && req.url.startsWith('/do-proxy/')) {
|
|
const filename = req.url.replace('/do-proxy/', '');
|
|
const targetUrl = `${DO_CONFIG.ENDPOINT}/${DO_CONFIG.CONTENT_PATH}/${filename}`;
|
|
|
|
logger.info(`🔗 DÉBUT - Proxy ${req.method} request: ${filename}`);
|
|
logger.info(`🌐 Target URL: ${targetUrl}`);
|
|
logger.info(`🔑 ACCESS_KEY: ${DO_CONFIG.ACCESS_KEY}`);
|
|
logger.info(`🔐 SECRET_KEY: ${DO_CONFIG.SECRET_KEY.substring(0, 8)}...`);
|
|
|
|
try {
|
|
logger.info(`🔧 Génération de la signature AWS...`);
|
|
const headers = await generateAWSSignature(req.method, targetUrl);
|
|
logger.info(`📋 Headers générés: ${JSON.stringify(headers, null, 2)}`);
|
|
|
|
logger.info(`📤 Envoi de la requête à DigitalOcean...`);
|
|
const fetchStart = Date.now();
|
|
|
|
const response = await fetch(targetUrl, {
|
|
method: req.method,
|
|
headers: headers
|
|
});
|
|
|
|
const fetchDuration = Date.now() - fetchStart;
|
|
logger.info(`⏱️ Durée requête DO: ${fetchDuration}ms`);
|
|
logger.info(`📡 Status DO: ${response.status} ${response.statusText}`);
|
|
logger.info(`📝 Headers réponse DO: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
|
|
|
|
if (response.ok) {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Access-Control-Allow-Origin': '*'
|
|
});
|
|
|
|
if (req.method === 'GET') {
|
|
const content = await response.text();
|
|
res.end(content);
|
|
logger.info(`✅ SUCCÈS - Proxy GET: ${filename} (${content.length} chars)`);
|
|
} else {
|
|
// HEAD request - no body
|
|
res.end();
|
|
logger.info(`✅ SUCCÈS - Proxy HEAD: ${filename} (headers only)`);
|
|
}
|
|
} else {
|
|
const errorText = await response.text();
|
|
logger.error(`❌ ERREUR DO - Status: ${response.status}, Body: ${errorText}`);
|
|
res.writeHead(response.status, {
|
|
'Content-Type': 'text/plain',
|
|
'Access-Control-Allow-Origin': '*'
|
|
});
|
|
res.end(errorText);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`💥 EXCEPTION PROXY - Type: ${error.constructor.name}`);
|
|
logger.error(`💥 Message: ${error.message}`);
|
|
logger.error(`💥 Stack: ${error.stack}`);
|
|
res.writeHead(500, {
|
|
'Content-Type': 'text/plain',
|
|
'Access-Control-Allow-Origin': '*'
|
|
});
|
|
res.end(`Error: ${error.message}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Route par défaut
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('Not Found\nUse: /do-proxy/filename.json');
|
|
});
|
|
|
|
httpServer.listen(httpPort, '0.0.0.0', () => {
|
|
logger.info(`🌐 Serveur HTTP proxy démarré sur le port ${httpPort} (toutes interfaces)`);
|
|
logger.info(`🔗 URL proxy: http://localhost:${httpPort}/do-proxy/filename.json`);
|
|
logger.info(`🌍 Accessible depuis Windows: http://localhost:${httpPort}/do-proxy/filename.json`);
|
|
});
|
|
|
|
// Créer le serveur WebSocket sur toutes les interfaces
|
|
const wss = new WebSocket.Server({ port: wsPort, host: '0.0.0.0' });
|
|
|
|
logger.info(`🚀 Serveur WebSocket démarré sur le port ${wsPort}`);
|
|
logger.info(`📡 En attente de connexions...`);
|
|
logger.info(`📝 Logs enregistrés dans: ${logFile}`);
|
|
|
|
// Garder trace des clients connectés
|
|
const clients = new Set();
|
|
|
|
wss.on('connection', function connection(ws) {
|
|
logger.info('✅ Nouveau client connecté');
|
|
clients.add(ws);
|
|
|
|
// Envoyer un message de bienvenue
|
|
const welcomeMessage = {
|
|
timestamp: new Date().toISOString(),
|
|
level: 'INFO',
|
|
message: '🎉 Connexion WebSocket établie - Logs en temps réel actifs'
|
|
};
|
|
ws.send(JSON.stringify(welcomeMessage));
|
|
|
|
// Gérer les messages du client (si nécessaire)
|
|
ws.on('message', function incoming(data) {
|
|
try {
|
|
const message = JSON.parse(data);
|
|
|
|
// Enregistrer le log dans le fichier via Pino
|
|
const level = (message.level || 'INFO').toLowerCase();
|
|
switch (level) {
|
|
case 'error':
|
|
logger.error(message.message);
|
|
break;
|
|
case 'warn':
|
|
case 'warning':
|
|
logger.warn(message.message);
|
|
break;
|
|
case 'debug':
|
|
logger.debug(message.message);
|
|
break;
|
|
case 'trace':
|
|
logger.trace(message.message);
|
|
break;
|
|
default:
|
|
logger.info(message.message);
|
|
}
|
|
|
|
// DIFFUSER LE LOG À TOUS LES CLIENTS CONNECTÉS !
|
|
broadcastLog(message);
|
|
} catch (error) {
|
|
logger.warn('📨 Message reçu (brut):', data.toString());
|
|
}
|
|
});
|
|
|
|
// Nettoyer quand le client se déconnecte
|
|
ws.on('close', function close() {
|
|
logger.info('❌ Client déconnecté');
|
|
clients.delete(ws);
|
|
});
|
|
|
|
// Gérer les erreurs
|
|
ws.on('error', function error(err) {
|
|
logger.error('❌ Erreur WebSocket:', err.message);
|
|
clients.delete(ws);
|
|
});
|
|
});
|
|
|
|
// Fonction pour diffuser un log à tous les clients connectés
|
|
function broadcastLog(logData) {
|
|
const message = JSON.stringify(logData);
|
|
let sentCount = 0;
|
|
|
|
clients.forEach(ws => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
ws.send(message);
|
|
sentCount++;
|
|
} catch (error) {
|
|
logger.error('❌ Erreur envoi vers client:', error.message);
|
|
clients.delete(ws);
|
|
}
|
|
} else {
|
|
// Nettoyer les connexions fermées
|
|
clients.delete(ws);
|
|
}
|
|
});
|
|
|
|
if (sentCount > 0) {
|
|
logger.debug(`📡 Log diffusé à ${sentCount} client(s): [${logData.level}] ${logData.message.substring(0, 50)}${logData.message.length > 50 ? '...' : ''}`);
|
|
}
|
|
}
|
|
|
|
// Parser la réponse XML S3 pour extraire les noms de fichiers
|
|
function parseS3ListResponse(xmlText) {
|
|
const files = [];
|
|
|
|
// Simple regex pour extraire les clés (noms de fichiers) du XML
|
|
const keyRegex = /<Key>([^<]+)<\/Key>/g;
|
|
let match;
|
|
|
|
while ((match = keyRegex.exec(xmlText)) !== null) {
|
|
const fullPath = match[1];
|
|
// Extraire juste le nom du fichier (après le dernier /)
|
|
const filename = fullPath.split('/').pop();
|
|
|
|
// Filtrer les fichiers vides et les dossiers
|
|
if (filename && filename.length > 0 && !filename.endsWith('/')) {
|
|
files.push(filename);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
// Export pour utilisation dans d'autres modules
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { broadcastLog, wss, clients };
|
|
}
|
|
|
|
// Gérer l'arrêt propre
|
|
process.on('SIGINT', () => {
|
|
logger.info('\n🛑 Arrêt du serveur WebSocket...');
|
|
|
|
// Fermer toutes les connexions
|
|
clients.forEach(ws => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
// Fermer le serveur
|
|
wss.close(() => {
|
|
logger.info('✅ Serveur WebSocket arrêté');
|
|
logger.flush();
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
// Message de test toutes les 30 secondes pour vérifier que ça marche
|
|
setInterval(() => {
|
|
if (clients.size > 0) {
|
|
const testMessage = {
|
|
timestamp: new Date().toISOString(),
|
|
level: 'DEBUG',
|
|
message: `💓 Heartbeat - ${clients.size} client(s) connecté(s)`
|
|
};
|
|
broadcastLog(testMessage);
|
|
}
|
|
}, 30000); |