858 lines
31 KiB
JavaScript
858 lines
31 KiB
JavaScript
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 TargetValidator = require('./services/target_validator');
|
|
|
|
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.serialize(() => {
|
|
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
|
|
)`);
|
|
|
|
db.run(`CREATE TABLE IF NOT EXISTS banned_wallets (
|
|
wallet_hash TEXT PRIMARY KEY,
|
|
reason TEXT,
|
|
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
banned_by TEXT DEFAULT 'Admin'
|
|
)`);
|
|
|
|
db.run(`CREATE TABLE IF NOT EXISTS upload_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
session_id TEXT,
|
|
wallet_hash TEXT,
|
|
image_filename TEXT,
|
|
json_filename TEXT,
|
|
file_size INTEGER,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
device_model TEXT,
|
|
device_os TEXT,
|
|
target_type TEXT,
|
|
weapon TEXT,
|
|
distance_meters INTEGER,
|
|
impacts_count INTEGER DEFAULT 0,
|
|
status TEXT DEFAULT 'SUCCESS',
|
|
target_valid INTEGER DEFAULT 1,
|
|
target_status TEXT DEFAULT 'VALID',
|
|
target_confidence REAL DEFAULT 1.0,
|
|
target_rings_count INTEGER DEFAULT 0,
|
|
target_details TEXT,
|
|
error_message TEXT,
|
|
raw_metadata TEXT
|
|
)`);
|
|
|
|
// Migration safe des colonnes OpenCV si la table existait déjà
|
|
const migrations = [
|
|
"ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1",
|
|
"ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'",
|
|
"ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0",
|
|
"ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0",
|
|
"ALTER TABLE upload_logs ADD COLUMN target_details TEXT"
|
|
];
|
|
migrations.forEach(sql => {
|
|
db.run(sql, () => {});
|
|
});
|
|
|
|
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`);
|
|
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Helper pour insérer un log d'upload
|
|
function logUploadEntry({
|
|
sessionId = null,
|
|
walletHash = null,
|
|
imageFilename = null,
|
|
jsonFilename = null,
|
|
fileSize = null,
|
|
ipAddress = null,
|
|
userAgent = null,
|
|
deviceModel = null,
|
|
deviceOs = null,
|
|
targetType = null,
|
|
weapon = null,
|
|
distanceMeters = null,
|
|
impactsCount = 0,
|
|
status = 'SUCCESS',
|
|
targetValid = 1,
|
|
targetStatus = 'VALID',
|
|
targetConfidence = 1.0,
|
|
targetRingsCount = 0,
|
|
targetDetails = null,
|
|
errorMessage = null,
|
|
rawMetadata = null
|
|
}) {
|
|
const query = `
|
|
INSERT INTO upload_logs (
|
|
session_id, wallet_hash, image_filename, json_filename, file_size,
|
|
ip_address, user_agent, device_model, device_os, target_type,
|
|
weapon, distance_meters, impacts_count, status,
|
|
target_valid, target_status, target_confidence, target_rings_count, target_details,
|
|
error_message, raw_metadata
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`;
|
|
db.run(query, [
|
|
sessionId,
|
|
walletHash,
|
|
imageFilename,
|
|
jsonFilename,
|
|
fileSize,
|
|
ipAddress,
|
|
userAgent,
|
|
deviceModel,
|
|
deviceOs,
|
|
targetType,
|
|
weapon,
|
|
distanceMeters,
|
|
impactsCount,
|
|
status,
|
|
targetValid ? 1 : 0,
|
|
targetStatus,
|
|
targetConfidence,
|
|
targetRingsCount,
|
|
targetDetails,
|
|
errorMessage,
|
|
rawMetadata ? JSON.stringify(rawMetadata) : null
|
|
], function(err) {
|
|
if (err) {
|
|
console.error("Erreur lors de l'insertion dans upload_logs:", err.message);
|
|
} else {
|
|
console.log(`[LOG] Upload enregistré (ID: ${this.lastID}) - Statut: ${status} - OpenCV: ${targetStatus}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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'), async (req, res) => {
|
|
const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
|
|
const userAgent = req.headers['user-agent'] || '';
|
|
|
|
try {
|
|
if (!req.file) {
|
|
logUploadEntry({
|
|
ipAddress,
|
|
userAgent,
|
|
status: 'FAILED',
|
|
errorMessage: 'Aucune photo fournie'
|
|
});
|
|
return res.status(400).json({
|
|
code: 'MISSING_PHOTO',
|
|
error: 'Aucune photo fournie dans la requête'
|
|
});
|
|
}
|
|
|
|
let plottingData = {};
|
|
if (req.body.plotting) {
|
|
try {
|
|
plottingData = JSON.parse(req.body.plotting);
|
|
} catch (e) {
|
|
console.error("Erreur parsing JSON:", e);
|
|
logUploadEntry({
|
|
imageFilename: req.file.filename,
|
|
fileSize: req.file.size,
|
|
ipAddress,
|
|
userAgent,
|
|
status: 'FAILED',
|
|
errorMessage: 'Le champ plotting doit être un JSON valide'
|
|
});
|
|
return res.status(400).json({
|
|
code: 'INVALID_PLOTTING_JSON',
|
|
error: 'Le champ plotting contient un format JSON invalide'
|
|
});
|
|
}
|
|
}
|
|
|
|
const walletHash = plottingData.wallet_hash || null;
|
|
const sessionId = plottingData.session_id || null;
|
|
|
|
// 1. Vérification du bannissement de wallet
|
|
if (walletHash) {
|
|
const bannedEntry = await new Promise((resolve) => {
|
|
db.get('SELECT * FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, row) => {
|
|
resolve(row || null);
|
|
});
|
|
});
|
|
|
|
if (bannedEntry) {
|
|
// Supprimer le fichier image temporaire pour ne pas consommer d'espace
|
|
if (req.file.path && fs.existsSync(req.file.path)) {
|
|
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
|
}
|
|
const banReason = bannedEntry.reason || 'Non-respect des règles de contribution / Image non conforme';
|
|
logUploadEntry({
|
|
sessionId,
|
|
walletHash,
|
|
imageFilename: req.file.filename,
|
|
fileSize: req.file.size,
|
|
ipAddress,
|
|
userAgent,
|
|
status: 'FAILED',
|
|
errorMessage: `Upload bloqué : wallet banni (${banReason})`
|
|
});
|
|
console.warn(`[MODÉRATION] Upload rejeté pour wallet banni: ${walletHash}`);
|
|
return res.status(403).json({
|
|
code: 'WALLET_BANNED',
|
|
error: 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
|
|
reason: banReason,
|
|
banned_at: bannedEntry.banned_at
|
|
});
|
|
}
|
|
}
|
|
|
|
// 2. Validation de la cible par OpenCV
|
|
let validation = {
|
|
isValid: true,
|
|
status: 'VALID',
|
|
confidence: 1.0,
|
|
ringsCount: 0,
|
|
bestCenter: null,
|
|
details: 'Analyse non effectuée'
|
|
};
|
|
|
|
try {
|
|
validation = await TargetValidator.validateTarget(req.file.path);
|
|
console.log(`[OPENCV] Diagnostic cible pour ${req.file.filename}: ${validation.status} (${Math.round(validation.confidence * 100)}% conf, ${validation.ringsCount} anneaux)`);
|
|
} catch (cvErr) {
|
|
console.error("[OPENCV] Erreur analyse cible:", cvErr);
|
|
}
|
|
|
|
// 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));
|
|
|
|
// Extraction des métadonnées
|
|
const deviceInfo = plottingData.device_info || {};
|
|
const targetMeta = plottingData.target_metadata || {};
|
|
const impacts = plottingData.plotting?.impacts || [];
|
|
const impactsCount = Array.isArray(impacts) ? impacts.length : 0;
|
|
|
|
// Mise à jour de la BDD si on a un 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)}...`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
|
|
logUploadEntry({
|
|
sessionId,
|
|
walletHash,
|
|
imageFilename: req.file.filename,
|
|
jsonFilename,
|
|
fileSize: req.file.size,
|
|
ipAddress,
|
|
userAgent,
|
|
deviceModel: deviceInfo.model || null,
|
|
deviceOs: deviceInfo.os || null,
|
|
targetType: targetMeta.type || null,
|
|
weapon: targetMeta.weapon || null,
|
|
distanceMeters: targetMeta.distance_meters || null,
|
|
impactsCount,
|
|
status: 'SUCCESS',
|
|
targetValid: validation.isValid ? 1 : 0,
|
|
targetStatus: validation.status,
|
|
targetConfidence: validation.confidence,
|
|
targetRingsCount: validation.ringsCount,
|
|
targetDetails: validation.details,
|
|
rawMetadata: plottingData
|
|
});
|
|
|
|
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({
|
|
code: 'UPLOAD_SUCCESS',
|
|
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
|
|
},
|
|
target_validation: validation
|
|
});
|
|
} catch (error) {
|
|
console.error('Erreur lors de l\'upload:', error);
|
|
logUploadEntry({
|
|
imageFilename: req.file ? req.file.filename : null,
|
|
fileSize: req.file ? req.file.size : null,
|
|
ipAddress,
|
|
userAgent,
|
|
status: 'FAILED',
|
|
errorMessage: error.message || 'Erreur interne lors de l\'upload'
|
|
});
|
|
res.status(500).json({
|
|
code: 'SERVER_ERROR',
|
|
error: error.message || 'Erreur interne du serveur lors de l\'upload'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Routes de Modération : Gestion des bannissements de wallets
|
|
app.get('/api/moderation/banned', (req, res) => {
|
|
db.all('SELECT * FROM banned_wallets ORDER BY banned_at DESC', [], (err, rows) => {
|
|
if (err) {
|
|
console.error("Erreur lecture wallets bannis:", err);
|
|
return res.status(500).json({ error: 'Erreur lecture wallets bannis' });
|
|
}
|
|
res.json({ banned: rows || [] });
|
|
});
|
|
});
|
|
|
|
app.post('/api/moderation/ban', (req, res) => {
|
|
const { wallet_hash, reason, banned_by } = req.body;
|
|
if (!wallet_hash) {
|
|
return res.status(400).json({ error: 'wallet_hash obligatoire' });
|
|
}
|
|
|
|
db.run(
|
|
`INSERT INTO banned_wallets (wallet_hash, reason, banned_by, banned_at)
|
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(wallet_hash) DO UPDATE SET
|
|
reason = excluded.reason,
|
|
banned_at = CURRENT_TIMESTAMP`,
|
|
[wallet_hash, reason || 'Contenu invalide ou non conforme aux règles', banned_by || 'Admin'],
|
|
function(err) {
|
|
if (err) {
|
|
console.error("Erreur bannissement:", err);
|
|
return res.status(500).json({ error: 'Erreur lors du bannissement du wallet' });
|
|
}
|
|
console.log(`[MODÉRATION] Wallet ${wallet_hash} banni (Motif: ${reason})`);
|
|
res.json({ message: 'Wallet banni avec succès', wallet_hash });
|
|
}
|
|
);
|
|
});
|
|
|
|
app.delete('/api/moderation/ban/:wallet_hash', (req, res) => {
|
|
const wallet_hash = req.params.wallet_hash;
|
|
db.run('DELETE FROM banned_wallets WHERE wallet_hash = ?', [wallet_hash], function(err) {
|
|
if (err) {
|
|
console.error("Erreur débannissement:", err);
|
|
return res.status(500).json({ error: 'Erreur lors du débannissement' });
|
|
}
|
|
console.log(`[MODÉRATION] Wallet ${wallet_hash} débanni`);
|
|
res.json({ message: 'Wallet débanni avec succès', wallet_hash });
|
|
});
|
|
});
|
|
|
|
// Route pour relancer l'analyse OpenCV sur une photo déjà existante
|
|
app.post('/api/moderation/verify/:filename', async (req, res) => {
|
|
const filename = req.params.filename;
|
|
const imgPath = path.join(imagesDir, filename);
|
|
if (!fs.existsSync(imgPath)) {
|
|
return res.status(404).json({ error: 'Image non trouvée' });
|
|
}
|
|
try {
|
|
const result = await TargetValidator.validateTarget(imgPath);
|
|
res.json({ filename, validation: result });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Route pour lister les logs avec filtres, recherche et pagination
|
|
app.get('/api/logs', (req, res) => {
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
|
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
const wallet = req.query.wallet || req.query.wallet_hash;
|
|
const status = req.query.status;
|
|
const search = req.query.search;
|
|
|
|
let whereClauses = [];
|
|
let params = [];
|
|
|
|
if (wallet) {
|
|
whereClauses.push("wallet_hash LIKE ?");
|
|
params.push(`%${wallet}%`);
|
|
}
|
|
if (status) {
|
|
whereClauses.push("status = ?");
|
|
params.push(status);
|
|
}
|
|
if (search) {
|
|
whereClauses.push("(session_id LIKE ? OR wallet_hash LIKE ? OR device_model LIKE ? OR weapon LIKE ? OR target_type LIKE ? OR image_filename LIKE ?)");
|
|
const s = `%${search}%`;
|
|
params.push(s, s, s, s, s, s);
|
|
}
|
|
|
|
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
|
|
|
db.get(`SELECT COUNT(*) as total FROM upload_logs ${whereSql}`, params, (err, countRow) => {
|
|
if (err) {
|
|
console.error("Erreur comptage logs:", err);
|
|
return res.status(500).json({ error: 'Erreur lors du comptage des logs' });
|
|
}
|
|
|
|
const total = countRow ? countRow.total : 0;
|
|
const query = `
|
|
SELECT * FROM upload_logs
|
|
${whereSql}
|
|
ORDER BY timestamp DESC, id DESC
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
db.all(query, [...params, limit, offset], (err, rows) => {
|
|
if (err) {
|
|
console.error("Erreur lecture logs:", err);
|
|
return res.status(500).json({ error: 'Erreur lors de la lecture des logs' });
|
|
}
|
|
res.json({
|
|
logs: rows,
|
|
total,
|
|
limit,
|
|
offset
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Route pour les statistiques des logs
|
|
app.get('/api/logs/stats', (req, res) => {
|
|
const statsQuery = `
|
|
SELECT
|
|
COUNT(*) as total_uploads,
|
|
SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
|
SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_count,
|
|
SUM(CASE WHEN date(timestamp, 'localtime') = date('now', 'localtime') THEN 1 ELSE 0 END) as today_uploads,
|
|
COUNT(DISTINCT wallet_hash) as unique_wallets,
|
|
SUM(COALESCE(file_size, 0)) as total_bytes,
|
|
MAX(timestamp) as latest_upload
|
|
FROM upload_logs
|
|
`;
|
|
|
|
db.get(statsQuery, [], (err, stats) => {
|
|
if (err) {
|
|
console.error("Erreur stats logs:", err);
|
|
return res.status(500).json({ error: 'Erreur calcul statistiques' });
|
|
}
|
|
res.json({
|
|
status: 'ok',
|
|
stats: stats || {
|
|
total_uploads: 0,
|
|
success_count: 0,
|
|
failed_count: 0,
|
|
today_uploads: 0,
|
|
unique_wallets: 0,
|
|
total_bytes: 0,
|
|
latest_upload: null
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Route pour exporter les logs (CSV ou JSON)
|
|
app.get('/api/logs/export', (req, res) => {
|
|
const format = req.query.format || 'csv';
|
|
|
|
db.all('SELECT * FROM upload_logs ORDER BY timestamp DESC, id DESC', [], (err, rows) => {
|
|
if (err) {
|
|
console.error("Erreur export logs:", err);
|
|
return res.status(500).json({ error: 'Erreur export logs' });
|
|
}
|
|
|
|
if (format === 'json') {
|
|
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.json`);
|
|
res.setHeader('Content-Type', 'application/json');
|
|
return res.send(JSON.stringify(rows, null, 2));
|
|
}
|
|
|
|
// CSV Export
|
|
const headers = [
|
|
'ID', 'Date/Heure', 'Statut', 'Wallet Hash', 'Session ID',
|
|
'Image', 'JSON', 'Taille (octets)', 'Cible', 'Arme', 'Distance (m)',
|
|
'Impacts', 'Modèle Appareil', 'OS', 'IP', 'Erreur'
|
|
];
|
|
|
|
const csvLines = [headers.join(';')];
|
|
|
|
rows.forEach(r => {
|
|
const line = [
|
|
r.id,
|
|
`"${r.timestamp || ''}"`,
|
|
`"${r.status || ''}"`,
|
|
`"${r.wallet_hash || ''}"`,
|
|
`"${r.session_id || ''}"`,
|
|
`"${r.image_filename || ''}"`,
|
|
`"${r.json_filename || ''}"`,
|
|
r.file_size || 0,
|
|
`"${r.target_type || ''}"`,
|
|
`"${r.weapon || ''}"`,
|
|
r.distance_meters || '',
|
|
r.impacts_count || 0,
|
|
`"${r.device_model || ''}"`,
|
|
`"${r.device_os || ''}"`,
|
|
`"${r.ip_address || ''}"`,
|
|
`"${(r.error_message || '').replace(/"/g, '""')}"`
|
|
];
|
|
csvLines.push(line.join(';'));
|
|
});
|
|
|
|
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.csv`);
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
res.send('\uFEFF' + csvLines.join('\r\n'));
|
|
});
|
|
});
|
|
|
|
// Route pour supprimer un log spécifique
|
|
app.delete('/api/logs/:id', (req, res) => {
|
|
const id = req.params.id;
|
|
db.run('DELETE FROM upload_logs WHERE id = ?', [id], function(err) {
|
|
if (err) {
|
|
console.error("Erreur suppression log:", err);
|
|
return res.status(500).json({ error: 'Erreur lors de la suppression' });
|
|
}
|
|
res.json({ message: 'Log supprimé avec succès', deletedId: id });
|
|
});
|
|
});
|
|
|
|
// Route pour vider les logs
|
|
app.delete('/api/logs', (req, res) => {
|
|
db.run('DELETE FROM upload_logs', [], function(err) {
|
|
if (err) {
|
|
console.error("Erreur vidage logs:", err);
|
|
return res.status(500).json({ error: 'Erreur lors de la réinitialisation des logs' });
|
|
}
|
|
res.json({ message: 'Tous les logs ont été effacés', changes: this.changes });
|
|
});
|
|
});
|
|
|
|
// 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 + statut de modération
|
|
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, statRow) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
|
|
}
|
|
|
|
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (bErr, bannedRow) => {
|
|
const isBanned = !!bannedRow;
|
|
const stats = statRow || { photo_count: 0, last_upload: null };
|
|
res.json({
|
|
status: 'ok',
|
|
stats,
|
|
is_banned: isBanned,
|
|
ban_reason: bannedRow ? bannedRow.reason : null,
|
|
banned_at: bannedRow ? bannedRow.banned_at : null
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Route directe pour vérifier l'état de modération d'un wallet
|
|
app.get('/api/moderation/status/:wallet_hash', (req, res) => {
|
|
const walletHash = req.params.wallet_hash;
|
|
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, bannedRow) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Erreur lecture statut modération' });
|
|
}
|
|
res.json({
|
|
wallet_hash: walletHash,
|
|
is_banned: !!bannedRow,
|
|
ban_reason: bannedRow ? bannedRow.reason : null,
|
|
banned_at: bannedRow ? bannedRow.banned_at : 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' });
|
|
});
|
|
|
|
// Intégration du Dashboard Next.js
|
|
const dashboardDir = path.join(__dirname, 'dashboard');
|
|
const dev = process.env.NODE_ENV !== 'production';
|
|
|
|
let nextApp;
|
|
try {
|
|
const next = require('next');
|
|
nextApp = next({ dev, dir: dashboardDir });
|
|
} catch (e) {
|
|
console.warn("Module 'next' non disponible, mode API seule.");
|
|
}
|
|
|
|
async function startServer() {
|
|
if (nextApp) {
|
|
try {
|
|
await nextApp.prepare();
|
|
const handle = nextApp.getRequestHandler();
|
|
app.all('*', (req, res) => handle(req, res));
|
|
console.log("Dashboard Next.js initialisé avec succès.");
|
|
} catch (err) {
|
|
console.error("Erreur d'initialisation du Dashboard Next.js:", err);
|
|
}
|
|
}
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`=================================`);
|
|
console.log(`Serveur Backend IA & Dashboard démarré`);
|
|
console.log(`Port: ${PORT}`);
|
|
console.log(`Dossiers:`);
|
|
console.log(` - Images: ${imagesDir}`);
|
|
console.log(` - Data : ${dataDir}`);
|
|
console.log(` - Export: ${exportsDir}`);
|
|
console.log(`=================================`);
|
|
});
|
|
}
|
|
|
|
startServer();
|