const express = require('express'); const multer = require('multer'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const sqlite3 = require('sqlite3').verbose(); const AdmZip = require('adm-zip'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(cors()); app.use(express.json()); // Arborescence demandée const uploadDir = path.join(__dirname, 'uploads'); const imagesDir = path.join(uploadDir, 'images'); const dataDir = path.join(uploadDir, 'data'); const exportsDir = path.join(__dirname, 'exports'); // S'assurer que les dossiers existent [uploadDir, imagesDir, dataDir, exportsDir].forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); // Initialisation de la base de données SQLite const dbPath = path.join(dataDir, 'database.sqlite'); const db = new sqlite3.Database(dbPath, (err) => { if (err) { console.error('Erreur de connexion à SQLite:', err.message); } else { console.log('Connecté à la base de données SQLite.'); db.run(`CREATE TABLE IF NOT EXISTS user_stats ( wallet_hash TEXT PRIMARY KEY, photo_count INTEGER DEFAULT 0, last_upload DATETIME DEFAULT CURRENT_TIMESTAMP )`); } }); // Configuration de multer pour le stockage des fichiers const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, imagesDir); // Les photos vont dans uploads/images/ }, filename: function (req, file, cb) { // Créer un nom de fichier unique tout en gardant l'extension originale const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const ext = path.extname(file.originalname); cb(null, file.fieldname + '-' + uniqueSuffix + ext); } }); const upload = multer({ storage: storage, limits: { fileSize: 15 * 1024 * 1024 // Limite de taille: 15 MB }, fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; // Accepter si c'est un mimetype image OU si l'extension est celle d'une image if (file.mimetype.startsWith('image/') || allowedExts.includes(ext) || file.mimetype === 'application/octet-stream') { cb(null, true); } else { console.error(`Fichier rejeté: mimetype=${file.mimetype}, extension=${ext}`); cb(new Error('Seules les images sont autorisées !'), false); } } }); // Routes de base app.get('/api/health', (req, res) => { res.json({ status: 'ok', message: 'Le serveur backend IA fonctionne' }); }); // Route pour l'upload de photo + données JSON // Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting' app.post('/api/upload', upload.single('photo'), (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'Aucune photo fournie' }); } let plottingData = {}; if (req.body.plotting) { try { plottingData = JSON.parse(req.body.plotting); } catch (e) { console.error("Erreur parsing JSON:", e); return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' }); } } // Nom de base sans l'extension const baseFilename = path.parse(req.file.filename).name; const jsonFilename = `${baseFilename}.json`; const jsonFilePath = path.join(dataDir, jsonFilename); // Sauvegarde du JSON dans uploads/data/ fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2)); // Mise à jour de la BDD si on a un wallet_hash const walletHash = plottingData.wallet_hash; if (walletHash) { db.run(` INSERT INTO user_stats (wallet_hash, photo_count, last_upload) VALUES (?, 1, CURRENT_TIMESTAMP) ON CONFLICT(wallet_hash) DO UPDATE SET photo_count = photo_count + 1, last_upload = CURRENT_TIMESTAMP `, [walletHash], function(err) { if (err) { console.error("Erreur lors de la mise à jour des stats :", err); } else { console.log(`Stats mises à jour pour le wallet_hash ${walletHash.substring(0, 8)}...`); } }); } console.log(`Données reçues et sauvegardées:`); console.log(`- Image: uploads/images/${req.file.filename}`); console.log(`- JSON : uploads/data/${jsonFilename}`); res.status(200).json({ message: 'Photo et données uploadées avec succès', file: { filename: req.file.filename, url: `/uploads/images/${req.file.filename}`, size: req.file.size }, data: { filename: jsonFilename } }); } catch (error) { console.error('Erreur lors de l\'upload:', error); res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' }); } }); // Route pour récupérer toutes les photos disponibles app.get('/api/photos', (req, res) => { try { fs.readdir(imagesDir, (err, files) => { if (err) { return res.status(500).json({ error: 'Impossible de lire le dossier des photos' }); } const photos = files.map(file => ({ filename: file, url: `/uploads/images/${file}` })); res.status(200).json({ photos }); }); } catch (error) { res.status(500).json({ error: 'Erreur serveur' }); } }); // Route pour récupérer les statistiques d'un wallet_hash app.get('/api/stats/:wallet_hash', (req, res) => { const walletHash = req.params.wallet_hash; db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => { if (err) { return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' }); } if (row) { res.json({ status: 'ok', stats: row }); } else { res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } }); } }); }); // Route pour récupérer TOUS les contributeurs app.get('/api/contributors', (req, res) => { db.all('SELECT * FROM user_stats ORDER BY photo_count DESC', [], (err, rows) => { if (err) { return res.status(500).json({ error: 'Erreur lors de la lecture des contributeurs' }); } res.json({ contributors: rows }); }); }); // Route pour récupérer toutes les données (Photos + JSON) app.get('/api/all-data', (req, res) => { try { fs.readdir(imagesDir, (err, files) => { if (err) { return res.status(500).json({ error: 'Impossible de lire le dossier des photos' }); } const allData = files.map(file => { const baseName = path.parse(file).name; const jsonPath = path.join(dataDir, `${baseName}.json`); let plotting = null; if (fs.existsSync(jsonPath)) { try { plotting = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); } catch (e) { console.error(`Erreur lecture JSON pour ${file}`); } } return { filename: file, imageUrl: `/uploads/images/${file}`, data: plotting }; }); res.status(200).json({ data: allData }); }); } catch (error) { res.status(500).json({ error: 'Erreur serveur' }); } }); // Route pour mettre à jour les données JSON d'une session app.put('/api/data/:filename', (req, res) => { const filename = req.params.filename; const baseName = path.parse(filename).name; const jsonPath = path.join(dataDir, `${baseName}.json`); try { if (!fs.existsSync(jsonPath)) { return res.status(404).json({ error: 'Fichier JSON non trouvé' }); } // On écrase le fichier avec les nouvelles données reçues const newData = req.body; fs.writeFileSync(jsonPath, JSON.stringify(newData, null, 2)); console.log(`Données mises à jour pour : ${baseName}.json`); res.status(200).json({ message: 'Données mises à jour avec succès' }); } catch (error) { console.error('Erreur lors de la mise à jour du JSON:', error); res.status(500).json({ error: 'Erreur serveur lors de la mise à jour' }); } }); // Route pour exporter toutes les données en ZIP app.get('/api/export/zip', (req, res) => { try { const zip = new AdmZip(); // Ajouter les dossiers au ZIP if (fs.existsSync(imagesDir)) zip.addLocalFolder(imagesDir, 'images'); if (fs.existsSync(dataDir)) zip.addLocalFolder(dataDir, 'data'); const zipFilename = `dataset_export_${Date.now()}.zip`; const zipPath = path.join(exportsDir, zipFilename); // Sauvegarder sur le disque zip.writeZip(zipPath); // Envoyer le fichier au client res.download(zipPath, zipFilename, (err) => { if (err) console.error("Erreur lors de l'envoi du ZIP:", err); // Optionnel: supprimer le ZIP local après envoi si vous voulez gagner de la place // fs.unlinkSync(zipPath); }); } catch (error) { console.error('Erreur lors de la création du ZIP:', error); res.status(500).json({ error: 'Erreur lors de la création de l\'archive' }); } }); // Route pour archiver et vider les dossiers uploads app.post('/api/export/archive', (req, res) => { try { const archiveName = `archive_${new Date().toISOString().replace(/[:.]/g, '-')}`; const archivePath = path.join(exportsDir, archiveName); if (!fs.existsSync(archivePath)) { fs.mkdirSync(archivePath, { recursive: true }); } // Fonction pour déplacer les fichiers const moveFiles = (source, destSubDir) => { const dest = path.join(archivePath, destSubDir); if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); const files = fs.readdirSync(source); files.forEach(file => { // Ne pas déplacer la base de données sqlite if (file === 'database.sqlite') return; const oldPath = path.join(source, file); const newPath = path.join(dest, file); try { fs.renameSync(oldPath, newPath); } catch (e) { // Fallback si renameSync échoue (ex: disque différent) fs.copyFileSync(oldPath, newPath); fs.unlinkSync(oldPath); } }); }; moveFiles(imagesDir, 'images'); moveFiles(dataDir, 'data'); res.json({ message: 'Données archivées avec succès', archive: archiveName }); } catch (error) { console.error('Erreur lors de l\'archivage:', error); res.status(500).json({ error: 'Erreur lors de l\'archivage des données' }); } }); // Servir les fichiers statiques (pour pouvoir accéder aux photos via URL) app.use('/uploads/images', express.static(imagesDir)); // Gestion des erreurs app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'Le fichier est trop volumineux (limite 15MB)' }); } } console.error(err.stack); res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' }); }); // Démarrer le serveur app.listen(PORT, () => { console.log(`=================================`); console.log(`Serveur Backend IA démarré`); console.log(`Port: ${PORT}`); console.log(`Dossiers:`); console.log(` - Images: ${imagesDir}`); console.log(` - Data : ${dataDir}`); console.log(` - Export: ${exportsDir}`); console.log(`=================================`); });