- Implémentation renameMedia avec retry (fix Windows file locking) - Implémentation deleteMedia via MQTT - Sécurisation des chemins d'accès (Directory Traversal prevention)
400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
const fs = require('fs');
|
|
const mqtt = require('mqtt');
|
|
const path = require('path');
|
|
const express = require('express');
|
|
const multer = require('multer');
|
|
const cors = require('cors');
|
|
|
|
// Lecture du fichier de configuration
|
|
const configPath = path.join(__dirname, '../config/configuration.json');
|
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
|
|
// Extraction des informations de config
|
|
const {
|
|
mqttHost,
|
|
services: {
|
|
session: {
|
|
MQTTconfig: {
|
|
mqttSessionRequestTopic,
|
|
mqttSessionGetTopic,
|
|
mqttSessionUpdateTopic,
|
|
mqttSessionListTopic,
|
|
mqttSessionListResponseTopic,
|
|
mqttSessionCreateTopic,
|
|
mqttSessionDeleteTopic,
|
|
mqttSessionDeleteMediaTopic,
|
|
mqttSessionRenameMediaTopic
|
|
},
|
|
httpPort
|
|
}
|
|
}
|
|
} = config;
|
|
|
|
const PORT = httpPort || 3000;
|
|
const quizzDir = path.join(__dirname, 'quizz');
|
|
|
|
// Configurer Express
|
|
const app = express();
|
|
app.use(cors());
|
|
|
|
// Configuration de Multer pour l'upload
|
|
const storage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
const sessionId = req.params.sessionId;
|
|
let subfolder = 'assets';
|
|
|
|
// Organiser par type (envoyé par le frontend dans req.body.type)
|
|
if (req.body.type === 'video') subfolder = 'assets/videos';
|
|
else if (req.body.type === 'audio') subfolder = 'assets/audios';
|
|
else if (req.body.type === 'picture') subfolder = 'assets/pictures';
|
|
|
|
const uploadPath = path.join(quizzDir, sessionId, subfolder);
|
|
|
|
// Créer le dossier s'il n'existe pas
|
|
fs.mkdirSync(uploadPath, { recursive: true });
|
|
cb(null, uploadPath);
|
|
},
|
|
filename: function (req, file, cb) {
|
|
// Utiliser l'ID de la question comme nom de fichier si disponible
|
|
const ext = path.extname(file.originalname);
|
|
let filename = 'file-' + Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
|
|
if (req.body.questionId) {
|
|
filename = req.body.questionId;
|
|
}
|
|
|
|
cb(null, filename + ext);
|
|
}
|
|
});
|
|
|
|
const upload = multer({ storage: storage });
|
|
|
|
// Route d'upload
|
|
app.post('/upload/:sessionId', upload.single('file'), (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).send('No file uploaded.');
|
|
}
|
|
|
|
// Calculer le chemin relatif pour l'URL
|
|
// req.file.path est le chemin absolu sur le disque
|
|
// On veut le chemin relatif à partir du dossier de session
|
|
const sessionId = req.params.sessionId;
|
|
const sessionDir = path.join(quizzDir, sessionId);
|
|
|
|
// path.relative(from, to) -> donne le chemin relatif
|
|
let relativeId = path.relative(sessionDir, req.file.path);
|
|
// Remplacer les backslashes par des slashs pour les URLs web
|
|
relativeId = relativeId.replace(/\\/g, '/');
|
|
|
|
// Ajouter le slash initial
|
|
const relativePath = `/${relativeId}`;
|
|
|
|
res.json({ path: relativePath, fullPath: req.file.path });
|
|
});
|
|
|
|
// App.use pour servir les fichiers statiques
|
|
app.use('/quizz', express.static(quizzDir));
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`[HTTP] Serveur d'upload démarré sur le port ${PORT}`);
|
|
});
|
|
|
|
|
|
// Connexion au broker MQTT
|
|
const client = mqtt.connect(mqttHost);
|
|
|
|
console.log("------------------------------------------------------------------------------");
|
|
console.log("[CONFIG] Session Manager chargé (Multi-Session + Upload)");
|
|
console.log("[CONFIG] Hôte MQTT :", mqttHost);
|
|
console.log("[CONFIG] Port HTTP :", PORT);
|
|
console.log("[CONFIG] Dossier Quizz :", quizzDir);
|
|
console.log("------------------------------------------------------------------------------");
|
|
|
|
client.on('connect', () => {
|
|
console.log(`[INFO] Connecté au broker MQTT à ${mqttHost}`);
|
|
|
|
client.subscribe(mqttSessionRequestTopic);
|
|
client.subscribe(mqttSessionUpdateTopic);
|
|
client.subscribe(mqttSessionListTopic);
|
|
client.subscribe(mqttSessionCreateTopic);
|
|
client.subscribe(mqttSessionDeleteTopic);
|
|
client.subscribe(mqttSessionDeleteMediaTopic);
|
|
client.subscribe(mqttSessionRenameMediaTopic);
|
|
|
|
console.log(`[INFO] Abonné aux topics session`);
|
|
});
|
|
|
|
client.on('message', (topic, message) => {
|
|
if (topic === mqttSessionListTopic) {
|
|
console.log(`[INFO] Demande de liste de sessions`);
|
|
sendSessionList();
|
|
} else if (topic === mqttSessionRequestTopic) {
|
|
try {
|
|
const payload = JSON.parse(message.toString());
|
|
console.log(`[INFO] Demande de configuration pour session: ${payload.SessionId}`);
|
|
if (payload.SessionId) {
|
|
sendSessionConfiguration(payload.SessionId);
|
|
}
|
|
} catch (e) { console.error("Erreur payload request", e); }
|
|
|
|
} else if (topic === mqttSessionUpdateTopic) {
|
|
try {
|
|
const payload = JSON.parse(message.toString());
|
|
console.log(`[INFO] Mise à jour configuration pour session: ${payload.SessionId}`);
|
|
if (payload.SessionId && payload.Config) {
|
|
saveSessionConfiguration(payload.SessionId, payload.Config);
|
|
}
|
|
} catch (e) {
|
|
console.error('[ERREUR] Impossible de parser la mise à jour', e);
|
|
}
|
|
} else if (topic === mqttSessionCreateTopic) {
|
|
try {
|
|
const payload = JSON.parse(message.toString());
|
|
console.log(`[INFO] Demande de création de session: ${payload.SessionName}`);
|
|
if (payload.SessionName) {
|
|
createSession(payload.SessionName);
|
|
}
|
|
} catch (e) {
|
|
console.error('[ERREUR] Impossible de parser la demande de création', e);
|
|
}
|
|
} else if (topic === mqttSessionDeleteTopic) {
|
|
try {
|
|
const payload = JSON.parse(message.toString());
|
|
console.log(`[INFO] Demande de suppression de session: ${payload.SessionId}`);
|
|
if (payload.SessionId) {
|
|
deleteSession(payload.SessionId);
|
|
}
|
|
} catch (e) {
|
|
console.error('[ERREUR] Impossible de parser la demande de suppression', e);
|
|
}
|
|
} else if (topic === mqttSessionDeleteMediaTopic) {
|
|
try {
|
|
const payload = JSON.parse(message.toString());
|
|
console.log(`[INFO] Demande de suppression de média: ${payload.MediaPath} pour session: ${payload.SessionId}`);
|
|
if (payload.SessionId && payload.MediaPath) {
|
|
deleteMedia(payload.SessionId, payload.MediaPath);
|
|
}
|
|
} catch (e) {
|
|
console.error('[ERREUR] Impossible de parser la demande de suppression de média', e);
|
|
}
|
|
} else if (topic === mqttSessionRenameMediaTopic) {
|
|
try {
|
|
const payload = JSON.parse(message.toString());
|
|
console.log(`[INFO] Demande de renommage de média: ${payload.OldPath} -> ${payload.NewName}`);
|
|
if (payload.SessionId && payload.OldPath && payload.NewName) {
|
|
renameMedia(payload.SessionId, payload.OldPath, payload.NewName);
|
|
}
|
|
} catch (e) {
|
|
console.error('[ERREUR] Impossible de parser la demande de renommage', e);
|
|
}
|
|
}
|
|
});
|
|
|
|
function sendSessionList() {
|
|
fs.readdir(quizzDir, { withFileTypes: true }, (err, entries) => {
|
|
if (err) {
|
|
console.error('[ERREUR] Lecture dossier quizz', err);
|
|
return;
|
|
}
|
|
|
|
const sessions = [];
|
|
entries.forEach(entry => {
|
|
if (entry.isDirectory()) {
|
|
const configPath = path.join(quizzDir, entry.name, 'session-configuration.json');
|
|
if (fs.existsSync(configPath)) {
|
|
try {
|
|
const sessConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
sessions.push({
|
|
id: entry.name,
|
|
title: sessConfig.PackTitle || entry.name
|
|
});
|
|
} catch (e) {
|
|
sessions.push({ id: entry.name, title: entry.name + " (Erreur Config)" });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
client.publish(mqttSessionListResponseTopic, JSON.stringify(sessions));
|
|
console.log(`[INFO] Liste envoyée : ${sessions.length} sessions`);
|
|
});
|
|
}
|
|
|
|
function sendSessionConfiguration(sessionId) {
|
|
const sessionFilePath = path.join(quizzDir, sessionId, 'session-configuration.json');
|
|
fs.readFile(sessionFilePath, 'utf8', (err, data) => {
|
|
if (err) {
|
|
console.error('[ERREUR] Impossible de lire le fichier de session', err);
|
|
return;
|
|
}
|
|
client.publish(mqttSessionGetTopic, data);
|
|
console.log(`[INFO] Configuration envoyée pour ${sessionId}`);
|
|
});
|
|
}
|
|
|
|
function saveSessionConfiguration(sessionId, newConfig) {
|
|
const sessionFilePath = path.join(quizzDir, sessionId, 'session-configuration.json');
|
|
|
|
// Validation
|
|
if (!newConfig.Questions || !Array.isArray(newConfig.Questions)) {
|
|
console.error('[ERREUR] Configuration invalide');
|
|
return;
|
|
}
|
|
|
|
const data = JSON.stringify(newConfig, null, 2);
|
|
fs.writeFile(sessionFilePath, data, (err) => {
|
|
if (err) {
|
|
console.error('[ERREUR] Impossible d\'écrire le fichier de session', err);
|
|
} else {
|
|
console.log(`[INFO] Session ${sessionId} mise à jour avec succès`);
|
|
// Confirmer la sauvegarde en renvoyant la config
|
|
client.publish(mqttSessionGetTopic, data);
|
|
}
|
|
});
|
|
}
|
|
|
|
function createSession(sessionName) {
|
|
// Nettoyer le nom pour le dossier
|
|
const safeName = sessionName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
|
const newSessionPath = path.join(quizzDir, safeName);
|
|
|
|
if (fs.existsSync(newSessionPath)) {
|
|
console.error(`[ERREUR] La session ${safeName} existe déjà`);
|
|
return;
|
|
}
|
|
|
|
// Créer le dossier
|
|
try {
|
|
fs.mkdirSync(newSessionPath, { recursive: true });
|
|
|
|
// Créer la structure de base
|
|
const defaultConfig = {
|
|
PackId: "",
|
|
PackTitle: sessionName,
|
|
Questions: []
|
|
};
|
|
|
|
fs.writeFileSync(
|
|
path.join(newSessionPath, 'session-configuration.json'),
|
|
JSON.stringify(defaultConfig, null, 2)
|
|
);
|
|
|
|
console.log(`[INFO] Nouvelle session créée: ${safeName}`);
|
|
|
|
// Renvoyer la liste mise à jour
|
|
sendSessionList();
|
|
|
|
} catch (e) {
|
|
console.error(`[ERREUR] Impossible de créer la session ${safeName}`, e);
|
|
}
|
|
}
|
|
|
|
function deleteSession(sessionId) {
|
|
const sessionPath = path.join(quizzDir, sessionId);
|
|
|
|
if (!fs.existsSync(sessionPath)) {
|
|
console.error(`[ERREUR] La session ${sessionId} n'existe pas`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs.rmSync(sessionPath, { recursive: true, force: true });
|
|
console.log(`[INFO] Session supprimée: ${sessionId}`);
|
|
|
|
// Renvoyer la liste mise à jour
|
|
sendSessionList();
|
|
|
|
} catch (e) {
|
|
console.error(`[ERREUR] Impossible de supprimer la session ${sessionId}`, e);
|
|
}
|
|
}
|
|
|
|
function deleteMedia(sessionId, relativePath) {
|
|
// Sécuriser le chemin
|
|
// relativePath devrait être du type "/assets/..."
|
|
if (!relativePath) return;
|
|
|
|
// Nettoyer le chemin (retirer le slash initial s'il existe)
|
|
if (relativePath.startsWith('/')) relativePath = relativePath.substring(1);
|
|
|
|
const fullPath = path.join(quizzDir, sessionId, relativePath);
|
|
|
|
// Vérifier que le chemin reste dans le dossier de la session (protection directory traversal)
|
|
if (!fullPath.startsWith(path.join(quizzDir, sessionId))) {
|
|
console.error(`[ERREUR] Tentative de suppression hors session: ${fullPath}`);
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(fullPath)) {
|
|
console.error(`[ERREUR] Le fichier ${fullPath} n'existe pas`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs.unlinkSync(fullPath);
|
|
console.log(`[INFO] Média supprimé: ${fullPath}`);
|
|
} catch (e) {
|
|
console.error(`[ERREUR] Impossible de supprimer le fichier ${fullPath}`, e);
|
|
}
|
|
}
|
|
|
|
client.on('error', (error) => {
|
|
console.error('[ERREUR] Erreur de connexion au broker MQTT:', error.message);
|
|
});
|
|
|
|
// Helper pour les renames tenaces sur Windows
|
|
function renameWithRetry(oldPath, newPath, retries = 5, delay = 100) {
|
|
try {
|
|
fs.renameSync(oldPath, newPath);
|
|
console.log(`[INFO] Média renommé : ${oldPath} -> ${newPath}`);
|
|
} catch (e) {
|
|
if (retries > 0) {
|
|
console.log(`[WARN] Échec renommage, nouvel essai dans ${delay}ms... (${retries} restants)`);
|
|
setTimeout(() => {
|
|
renameWithRetry(oldPath, newPath, retries - 1, delay * 2);
|
|
}, delay);
|
|
} else {
|
|
console.error(`[ERREUR] Impossible de renommer le fichier après plusieurs essais`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renameMedia(sessionId, oldRelativePath, newName) {
|
|
if (!oldRelativePath || !newName) return;
|
|
|
|
// Clean paths
|
|
if (oldRelativePath.startsWith('/')) oldRelativePath = oldRelativePath.substring(1);
|
|
|
|
const oldFullPath = path.join(quizzDir, sessionId, oldRelativePath);
|
|
|
|
// Security check
|
|
if (!oldFullPath.startsWith(path.join(quizzDir, sessionId))) {
|
|
console.error(`[ERREUR] Tentative de renommage hors session`);
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(oldFullPath)) {
|
|
console.error(`[ERREUR] Fichier à renommer introuvable: ${oldFullPath}`);
|
|
return;
|
|
}
|
|
|
|
const dir = path.dirname(oldFullPath);
|
|
const ext = path.extname(oldFullPath);
|
|
// newName comes as "Q-005", we add the extension
|
|
const newFilename = newName + ext;
|
|
const newFullPath = path.join(dir, newFilename);
|
|
|
|
// Prevent overwriting existing files (check & delete)
|
|
if (fs.existsSync(newFullPath)) {
|
|
console.log(`[INFO] Le fichier de destination existe déjà, on supprime : ${newFullPath}`);
|
|
try {
|
|
fs.unlinkSync(newFullPath);
|
|
} catch (e) {
|
|
console.error(`[ERREUR] Impossible de supprimer le fichier existant ${newFullPath}`, e);
|
|
// On continue quand même pour tenter le rename (le rename écrasera peut-être ou échouera)
|
|
}
|
|
}
|
|
|
|
renameWithRetry(oldFullPath, newFullPath);
|
|
}
|