// server.js - VERSION PRODUCTION FINALE AVEC COLLABORATEURS (CORRIGÉ async/await) // 🛡️ Sécurité Max | 🤖 Modération IA | 📝 Logs Structurés | 💾 Persistance /data | ⚡ Anti-Brute-Force | 👥 Collaborateurs import express from 'express'; import { fileURLToPath } from 'url'; import { dirname, join, resolve, extname } from 'path'; import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs'; import sqlite3 from 'better-sqlite3'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import cookieParser from 'cookie-parser'; import multer from 'multer'; import sanitizeHtml from 'sanitize-html'; import Groq from 'groq-sdk'; import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import cors from 'cors'; import crypto from 'crypto'; import dotenv from 'dotenv'; // ========================================== // 🎨 LOGGER STRUCTURÉ & BEAU (ANSI COLORS) // ========================================== const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ALERT: 3, ERROR: 4, FATAL: 5 }; const CURRENT_LOG_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase()] ?? LOG_LEVELS.INFO; const COLORS = { RESET: '\x1b[0m', BOLD: '\x1b[1m', DIM: '\x1b[2m', DEBUG: '\x1b[36m', INFO: '\x1b[32m', WARN: '\x1b[33m', ALERT: '\x1b[35m', ERROR: '\x1b[31m', FATAL: '\x1b[41m', META: '\x1b[90m', }; class Logger { constructor(context = 'APP') { this.context = context; this.baseMeta = {}; } child(meta) { const l = new Logger(this.context); l.baseMeta = { ...this.baseMeta, ...meta }; return l; } _log(level, msg, meta = {}) { if (LOG_LEVELS[level] < CURRENT_LOG_LEVEL) return; const ts = new Date().toISOString(); const color = COLORS[level] || COLORS.RESET; const badge = `${color}${COLORS.BOLD}[${level}]${COLORS.RESET}`; const ctx = `${COLORS.DIM}[${this.context}]${COLORS.RESET}`; const base = this.baseMeta ? `${COLORS.META}${JSON.stringify(this.baseMeta)}${COLORS.RESET} ` : ''; const metaStr = Object.keys(meta).length ? ` ${COLORS.META}${level >= 'ERROR' ? JSON.stringify(meta, null, 2) : JSON.stringify(meta)}${COLORS.RESET}` : ''; process.stdout.write(`${COLORS.DIM}${ts}${COLORS.RESET} ${badge} ${ctx} ${base}${msg}${metaStr}\n`); } debug(msg, meta) { this._log('DEBUG', msg, meta); } info(msg, meta) { this._log('INFO', msg, meta); } warn(msg, meta) { this._log('WARN', msg, meta); } alert(msg, meta) { this._log('ALERT', msg, meta); } error(msg, meta) { this._log('ERROR', msg, meta); } fatal(msg, meta) { this._log('FATAL', msg, meta); process.exit(1); } } const log = new Logger('BOOT'); // ========================================== // ⚙️ CONFIG & ENV // ========================================== dotenv.config({ path: resolve(dirname(fileURLToPath(import.meta.url)), '.env') }); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PORT = process.env.PORT || 7860; const NODE_ENV = process.env.NODE_ENV || 'development'; const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me_now'; const IP_PEPPER = process.env.IP_PEPPER || 'dev_pepper_change_me_now'; const GROQ_API_KEY = process.env.GROQ_API_KEY; const GROQ_MODELS = (process.env.GROQ_MODELS || 'llama-3.3-70b-versatile,llama-3.1-8b-instant').split(',').map(m => m.trim()); const DB_PATH = resolve(process.env.DB_PATH || join(__dirname, 'data', 'db.sqlite')); const UPLOAD_DIR = resolve(process.env.UPLOAD_DIR || join(__dirname, 'data', 'uploads')); const DB_DIR = dirname(DB_PATH); const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 15 * 1024 * 1024; const MAX_FILES = 3; const MAX_MSG_LEN = 1000; const RATE_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000; const RATE_LIMIT_MAX = 20; const MAX_LINKS_PER_USER = 10; const BCRYPT_COST = 12; // ========================================== // 💾 VÉRIFICATION PERSISTANCE (FAIL FAST) // ========================================== log.info('Vérification persistance disque...', { dbPath: DB_PATH, uploadDir: UPLOAD_DIR, dbDir: DB_DIR }); try { mkdirSync(DB_DIR, { recursive: true }); mkdirSync(UPLOAD_DIR, { recursive: true }); writeFileSync(join(DB_DIR, '.write_test'), 'ok'); unlinkSync(join(DB_DIR, '.write_test')); writeFileSync(join(UPLOAD_DIR, '.write_test'), 'ok'); unlinkSync(join(UPLOAD_DIR, '.write_test')); log.info('✅ Écriture disque CONFIRMÉE (Persistance OK)'); } catch (e) { log.fatal('❌ ERREUR CRITIQUE DISQUE - Impossible d\'écrire sur le volume', { error: e.message, dbDir: DB_DIR, uploadDir: UPLOAD_DIR }); } // ========================================== // 🗄️ INIT DB + MIGRATION COLLABORATEURS // ========================================== const db = sqlite3(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.pragma('synchronous = NORMAL'); const schema = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL COLLATE NOCASE, password_hash TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); CREATE TABLE IF NOT EXISTS links ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, link_id INTEGER NOT NULL, ip_hash TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (link_id) REFERENCES links(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS images ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL, filename TEXT NOT NULL, stored_name TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL, size INTEGER NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS bans ( id INTEGER PRIMARY KEY AUTOINCREMENT, link_id INTEGER NOT NULL, ip_hash TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')), UNIQUE(link_id, ip_hash), FOREIGN KEY (link_id) REFERENCES links(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS rate_limits ( id INTEGER PRIMARY KEY AUTOINCREMENT, link_id INTEGER NOT NULL, ip_hash TEXT NOT NULL, count INTEGER DEFAULT 1, window_start INTEGER DEFAULT (strftime('%s', 'now')), UNIQUE(link_id, ip_hash, window_start) ); -- 👥 NOUVELLE TABLE COLLABORATEURS CREATE TABLE IF NOT EXISTS collaborators ( link_id INTEGER NOT NULL, user_id INTEGER NOT NULL, role TEXT NOT NULL DEFAULT 'moderator', -- 'moderator' | 'viewer' created_at INTEGER DEFAULT (strftime('%s', 'now')), PRIMARY KEY (link_id, user_id), FOREIGN KEY (link_id) REFERENCES links(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_collab_user ON collaborators(user_id); CREATE INDEX IF NOT EXISTS idx_messages_link ON messages(link_id); CREATE INDEX IF NOT EXISTS idx_images_msg ON images(message_id); CREATE INDEX IF NOT EXISTS idx_bans_link_ip ON bans(link_id, ip_hash); CREATE INDEX IF NOT EXISTS idx_ratelimit_lookup ON rate_limits(link_id, ip_hash, window_start); `; db.exec(schema); log.info('✅ Schema DB prêt & WAL activé (Table collaborators incluse)', { path: DB_PATH }); // ========================================== // 🛠️ HELPERS & MIDDLEWARE AUTORISATION // ========================================== const hashIp = (ip) => crypto.createHmac('sha256', IP_PEPPER).update(ip).digest('hex'); const groq = GROQ_API_KEY ? new Groq({ apiKey: GROQ_API_KEY }) : null; const getClientIp = (req) => req.ip || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || 'unknown'; // --- MODÉRATION IA GROQ --- async function moderateContent(text) { if (!groq) return { safe: true, reason: 'Groq non configuré (Mode Dev)' }; if (!text || text.trim().length === 0) return { safe: true, reason: 'Vide' }; const prompt = `Analyse le texte suivant pour la modération. Réponds UNIQUEMENT par un JSON valide : {"safe": true/false, "reason": "catégorie"}. Catégories: hate, harassment, violence, illegal, sexual, self-harm, pii, spam, safe. Texte: """${text.slice(0, 2000)}"""`; for (const model of GROQ_MODELS) { try { const chatCompletion = await groq.chat.completions.create({ messages: [{ role: 'user', content: prompt }], model, temperature: 0.0, response_format: { type: "json_object" }, max_tokens: 100, }, { timeout: 10000 }); const response = JSON.parse(chatCompletion.choices[0].message.content); if (response.safe === false) return { safe: false, reason: response.reason || 'Unsafe content', model }; return { safe: true, reason: 'OK', model }; } catch (e) { log.warn(`[Moderation] Modèle ${model} échoué`, { error: e.message }); } } return { safe: false, reason: 'moderation_unavailable_all_models_failed' }; } // --- HELPER AUTORISATION LIEN CENTRALISÉ --- /** * Vérifie l'accès d'un user à un lien. * @returns {Promise<'owner'|'moderator'|'viewer'|null>} */ async function checkLinkAccess(linkId, userId) { const owner = db.prepare('SELECT 1 FROM links WHERE id = ? AND user_id = ?').get(linkId, userId); if (owner) return 'owner'; const collab = db.prepare('SELECT role FROM collaborators WHERE link_id = ? AND user_id = ?').get(linkId, userId); if (collab) return collab.role; return null; } /** * Middleware factory pour protéger les routes /api/admin/links/:id/* * @param {Array<'owner'|'moderator'|'viewer'>} requiredRoles */ const linkAccessMiddleware = (requiredRoles = ['owner', 'moderator']) => async (req, res, next) => { const linkId = parseInt(req.params.id, 10); if (isNaN(linkId)) return res.status(400).json({ error: 'ID lien invalide' }); const role = await checkLinkAccess(linkId, req.user.id); if (!role || !requiredRoles.includes(role)) { return req.log.warn('Accès admin lien refusé', { linkId, userRole: role, required: requiredRoles }), res.status(403).json({ error: 'Non autorisé sur ce lien' }); } req.linkAccess = { linkId, role }; next(); }; const authMiddleware = (req, res, next) => { const token = req.cookies?.token; if (!token) return res.status(401).json({ error: 'Non authentifié' }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch (e) { res.clearCookie('token'); return res.status(401).json({ error: 'Session expirée' }); } }; // ========================================== // 🌐 EXPRESS APP & MIDDLEWARES GLOBAUX // ========================================== const app = express(); app.set('trust proxy', 1); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "blob:"], connectSrc: ["'self'"], fontSrc: ["'self'", "data:"], objectSrc: ["'none'"], frameAncestors: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"] } }, crossOriginEmbedderPolicy: false, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true } })); app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(cookieParser()); app.use((req, res, next) => { req.id = crypto.randomBytes(8).toString('hex'); req.log = log.child({ reqId: req.id, ip: req.ip, ua: req.get('user-agent')?.slice(0, 50) }); const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; const level = res.statusCode >= 500 ? 'ERROR' : res.statusCode >= 400 ? 'WARN' : 'INFO'; req.log[level.toLowerCase()](`${req.method} ${req.originalUrl}`, { status: res.statusCode, duration: `${duration}ms` }); }); next(); }); app.use(express.static(join(__dirname, 'public'), { maxAge: '1d', etag: true })); // ========================================== // 🛡️ ROUTE IMAGES PROTÉGÉE (Proprio + Collaborateurs) -- CORRECTION ICI : async (req, res) // ========================================== app.get('/uploads/:stored_name', async (req, res) => { // <--- AJOUT DE async ICI const rlog = req.log.child({ route: 'GET_IMAGE', file: req.params.stored_name }); try { const imageRecord = db.prepare('SELECT message_id, mime_type FROM images WHERE stored_name = ?').get(req.params.stored_name); if (!imageRecord) return res.status(404).send('Image non trouvée'); const messageRecord = db.prepare('SELECT link_id FROM messages WHERE id = ?').get(imageRecord.message_id); if (!messageRecord) return res.status(404).send('Message introuvable'); const token = req.cookies?.token; if (!token) return res.status(403).send('Connexion requise'); let userId; try { userId = jwt.verify(token, JWT_SECRET).id; } catch (e) { return res.status(403).send('Token invalide'); } // Vérif accès (Owner ou Collaborateur) - checkLinkAccess est async const role = await checkLinkAccess(messageRecord.link_id, userId); if (!role) { rlog.alert('Tentative accès image non autorisé', { linkId: messageRecord.link_id, userId }); return res.status(403).send('Accès refusé'); } const filePath = join(UPLOAD_DIR, req.params.stored_name); if (!filePath.startsWith(UPLOAD_DIR) || !existsSync(filePath)) return res.status(404).send('Fichier manquant'); res.setHeader('Content-Type', imageRecord.mime_type); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Cache-Control', 'private, max-age=3600'); res.sendFile(filePath); } catch (e) { rlog.error('Erreur service image', { err: e.message }); res.status(500).send('Erreur serveur'); } }); // ========================================== // 📦 MULTER CONFIG // ========================================== const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, UPLOAD_DIR), filename: (req, file, cb) => { const ext = extname(file.originalname).toLowerCase().substring(0, 5); const allowedExt = ['.jpg', '.jpeg', '.png', '.webp', '.gif']; const safeExt = allowedExt.includes(ext) ? ext : '.bin'; cb(null, crypto.randomBytes(16).toString('hex') + safeExt); } }); const fileFilter = (req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; allowed.includes(file.mimetype) ? cb(null, true) : cb(new Error('Format interdit (JPG, PNG, WebP, GIF)'), false); }; const upload = multer({ storage, fileFilter, limits: { fileSize: MAX_FILE_SIZE, files: MAX_FILES, parts: MAX_FILES + 5 } }); // ========================================== // 🔐 RATE LIMITERS ANTI-BRUTE-FORCE // ========================================== app.use('/api/', rateLimit({ windowMs: 60e3, max: 100, standardHeaders: true, legacyHeaders: false, message: { error: 'Trop de requêtes, ralentissez.' } })); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, keyGenerator: (req) => `${req.ip}:${req.body.username?.toLowerCase() || 'unknown'}`, message: { error: 'Trop de tentatives de connexion pour cet utilisateur. Réessayez dans 15 min.' }, standardHeaders: true, legacyHeaders: false, handler: (req, res, next, options) => { req.log.warn('🛑 LOGIN BRUTE FORCE DÉTECTÉ', { username: req.body.username, ip: req.ip }); res.status(429).json(options.message); } }); const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 3, message: { error: 'Trop de comptes créés depuis cette IP. Réessayez plus tard.' }, standardHeaders: true, legacyHeaders: false, }); const authLimiter = rateLimit({ windowMs: 15 * 60e3, max: 20, message: { error: 'Trop de tentatives auth.' } }); // ========================================== // 🛣️ ROUTES AUTH // ========================================== app.post('/api/auth/register', registerLimiter, async (req, res) => { const rlog = req.log.child({ route: 'REGISTER' }); const { username, password } = req.body; if (!username || !password || username.length < 3 || username.length > 30 || password.length < 8 || password.length > 128) { return rlog.warn('Validation échouée', { username }), res.status(400).json({ error: 'User 3-30 chars, Pass 8-128' }); } try { const hash = await bcrypt.hash(password, BCRYPT_COST); const info = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, hash); const token = jwt.sign({ id: info.lastInsertRowid, username }, JWT_SECRET, { expiresIn: '30d' }); res.cookie('token', token, { httpOnly: true, secure: NODE_ENV === 'production', sameSite: 'lax', maxAge: 30*24*3600e3, path: '/' }); rlog.info('✅ Nouvel utilisateur créé', { userId: info.lastInsertRowid, username }); res.json({ success: true, user: { id: info.lastInsertRowid, username } }); } catch (e) { if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return rlog.warn('User existe déjà', { username }), res.status(409).json({ error: 'Nom d\'utilisateur déjà pris' }); rlog.error('Erreur création user', { err: e.message }); res.status(500).json({ error: 'Erreur serveur' }); } }); app.post('/api/auth/login', loginLimiter, async (req, res) => { const rlog = req.log.child({ route: 'LOGIN' }); const { username, password } = req.body; const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username); const dummyHash = '$2b$12$dummyhashdummyhashdummyhashdummyhashdummyhashdummyhashdum'; const hashToCheck = user?.password_hash || dummyHash; const isValid = user && await bcrypt.compare(password, hashToCheck); if (!user || !isValid) { rlog.warn('❌ Échec connexion', { username: username || 'vide', userExists: !!user }); return res.status(401).json({ error: 'Identifiants incorrects' }); } const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' }); res.cookie('token', token, { httpOnly: true, secure: NODE_ENV === 'production', sameSite: 'lax', maxAge: 30*24*3600e3, path: '/' }); rlog.info('✅ Connexion réussie', { userId: user.id }); res.json({ success: true, user: { id: user.id, username: user.username } }); }); app.post('/api/auth/logout', (req, res) => { res.clearCookie('token', { path: '/', httpOnly: true, secure: NODE_ENV === 'production', sameSite: 'lax' }); req.log.info('Déconnexion'); res.json({ success: true }); }); app.get('/api/auth/me', authMiddleware, (req, res) => res.json({ user: { id: req.user.id, username: req.user.username } })); // ========================================== // 🔗 ROUTES LIENS (Dashboard Owner) // ========================================== app.get('/api/links', authMiddleware, (req, res) => { const rlog = req.log.child({ route: 'LIST_LINKS', userId: req.user.id }); const links = db.prepare(`SELECT l.id, l.slug, l.created_at, (SELECT COUNT(*) FROM messages m WHERE m.link_id = l.id) as msg_count, (SELECT COUNT(*) FROM collaborators c WHERE c.link_id = l.id) as collab_count FROM links l WHERE l.user_id = ? ORDER BY l.created_at DESC`).all(req.user.id); rlog.debug('Liste liens récupérée', { count: links.length }); res.json({ links }); }); app.post('/api/links', authMiddleware, (req, res) => { const rlog = req.log.child({ route: 'CREATE_LINK', userId: req.user.id }); const { slug } = req.body; rlog.info('Tentative création lien', { slug }); if (!slug || !/^[a-z0-9-]{3,50}$/.test(slug)) return rlog.warn('Slug invalide'), res.status(400).json({ error: 'Slug invalide (a-z, 0-9, -)' }); if (db.prepare('SELECT COUNT(*) as c FROM links WHERE user_id = ?').get(req.user.id).c >= MAX_LINKS_PER_USER) return rlog.warn('Limite liens'), res.status(403).json({ error: `Max ${MAX_LINKS_PER_USER} liens` }); try { const info = db.prepare('INSERT INTO links (slug, user_id) VALUES (?, ?)').run(slug.toLowerCase(), req.user.id); rlog.info('✅ Lien CRÉÉ et SAUVÉ', { linkId: info.lastInsertRowid, slug: slug.toLowerCase(), dbPath: DB_PATH }); res.status(201).json({ success: true, link: { id: info.lastInsertRowid, slug: slug.toLowerCase() } }); } catch (e) { if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return rlog.warn('Slug déjà pris'), res.status(409).json({ error: 'Slug pris' }); rlog.error('Erreur DB insertion lien', { err: e.message }); res.status(500).json({ error: 'Erreur serveur' }); } }); app.delete('/api/links/:id', authMiddleware, (req, res) => { const rlog = req.log.child({ route: 'DELETE_LINK', userId: req.user.id, linkId: req.params.id }); const info = db.prepare('DELETE FROM links WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); if (info.changes === 0) return rlog.warn('Introuvable'), res.status(404).json({ error: 'Introuvable' }); rlog.info('🗑️ Lien supprimé (cascade)'); res.json({ success: true }); }); // ========================================== // 👥 ROUTES COLLABORATEURS (Owner Only) // ========================================== app.get('/api/links/:id/collaborators', authMiddleware, linkAccessMiddleware(['owner']), (req, res) => { const collabs = db.prepare(`SELECT c.user_id, c.role, c.created_at, u.username FROM collaborators c JOIN users u ON c.user_id = u.id WHERE c.link_id = ?`).all(req.linkAccess.linkId); res.json({ collaborators: collabs }); }); app.post('/api/links/:id/collaborators', authMiddleware, linkAccessMiddleware(['owner']), async (req, res) => { const rlog = req.log.child({ route: 'ADD_COLLAB', linkId: req.linkAccess.linkId }); const { username, role = 'moderator' } = req.body; if (!username || !['moderator', 'viewer'].includes(role)) return res.status(400).json({ error: 'Username et rôle valide (moderator/viewer) requis' }); const targetUser = db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(username); if (!targetUser) return rlog.warn('User cible introuvable', { username }), res.status(404).json({ error: 'Utilisateur introuvable' }); if (targetUser.id === req.user.id) return res.status(400).json({ error: 'Vous êtes déjà propriétaire' }); try { db.prepare('INSERT OR REPLACE INTO collaborators (link_id, user_id, role) VALUES (?, ?, ?)').run(req.linkAccess.linkId, targetUser.id, role); rlog.info('✅ Collaborateur ajouté', { targetUserId: targetUser.id, role }); res.json({ success: true, collaborator: { user_id: targetUser.id, username, role } }); } catch (e) { rlog.error('Erreur ajout collab', e); res.status(500).json({ error: 'Erreur serveur' }); } }); app.delete('/api/links/:id/collaborators/:userId', authMiddleware, linkAccessMiddleware(['owner']), (req, res) => { const rlog = req.log.child({ route: 'DEL_COLLAB', linkId: req.linkAccess.linkId }); const targetUserId = parseInt(req.params.userId, 10); db.prepare('DELETE FROM collaborators WHERE link_id = ? AND user_id = ?').run(req.linkAccess.linkId, targetUserId); rlog.info('🗑️ Collaborateur retiré', { targetUserId }); res.json({ success: true }); }); // ========================================== // 📬 ROUTES PUBLIQUES // ========================================== app.get('/:slug', (req, res, next) => { if (db.prepare('SELECT 1 FROM links WHERE slug = ?').get(req.params.slug)) res.sendFile(join(__dirname, 'public', 'message.html')); else next(); }); app.get('/api/public/:slug', (req, res) => { const rlog = req.log.child({ route: 'PUBLIC_MESSAGES', slug: req.params.slug }); const link = db.prepare('SELECT id, slug FROM links WHERE slug = ?').get(req.params.slug); if (!link) return rlog.warn('Lien public introuvable'), res.status(404).json({ error: 'Lien introuvable' }); const messages = db.prepare(`SELECT m.id, m.content, m.created_at, (SELECT json_group_array(json_object('id', i.id, 'url', '/uploads/' || i.stored_name, 'filename', i.filename)) FROM images i WHERE i.message_id = m.id) as images FROM messages m WHERE m.link_id = ? ORDER BY m.created_at DESC LIMIT 50`).all(link.id); let isOwner = false; const token = req.cookies?.token; if (token) { try { if (db.prepare('SELECT 1 FROM links WHERE id = ? AND user_id = ?').get(link.id, jwt.verify(token, JWT_SECRET).id)) isOwner = true; } catch(e) {} } rlog.debug('Messages publics servis', { count: messages.length, isOwner }); res.json({ link: { slug: link.slug }, messages: messages.map(m => ({ id: m.id, content: m.content, created_at: m.created_at, images: isOwner ? (m.images ? JSON.parse(m.images) : []) : [] })) }); }); // ========================================== // 📩 POST MESSAGE (ANONYME + LÉGAL + MODÉRATION IA) // ========================================== app.post('/api/public/:slug/message', upload.array('images', MAX_FILES), async (req, res) => { let rlog = req.log.child({ route: 'POST_MESSAGE', slug: req.params.slug }); const link = db.prepare('SELECT id FROM links WHERE slug = ?').get(req.params.slug); if (!link) { req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); return rlog.warn('Post sur lien inexistant'), res.status(404).json({ error: 'Lien introuvable' }); } const linkId = link.id; const ipHash = hashIp(getClientIp(req)); rlog = rlog.child({ linkId, ipHash }); if (db.prepare('SELECT 1 FROM bans WHERE link_id = ? AND ip_hash = ?').get(linkId, ipHash)) { req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); return rlog.alert('IP Bannie tente de poster'), res.status(403).json({ error: 'Banni de ce lien' }); } const windowStart = Math.floor(Date.now() / 1000) - (RATE_LIMIT_WINDOW_MS / 1000); db.prepare('DELETE FROM rate_limits WHERE window_start < ?').run(windowStart); if ((db.prepare('SELECT count FROM rate_limits WHERE link_id = ? AND ip_hash = ? AND window_start >= ?').get(linkId, ipHash, windowStart) || {count:0}).count >= RATE_LIMIT_MAX) { req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); return rlog.warn('Rate limit dépassé'), res.status(429).json({ error: `Limite ${RATE_LIMIT_MAX} msg/jour atteinte` }); } let content = req.body.content?.toString().trim() || ''; const legalAccepted = req.body.legal_accepted === 'true' || req.body.legal_accepted === true; rlog.debug('Payload reçu', { contentLen: content.length, files: req.files?.length, legalAccepted }); if (!legalAccepted) { req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); return rlog.warn('Refus: Legal non accepté'), res.status(400).json({ error: 'Validation légale requise (case à cocher)' }); } if (content.length === 0 && (!req.files || req.files.length === 0)) { req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); return rlog.warn('Refus: Contenu vide'), res.status(400).json({ error: 'Message ou image requis' }); } if (content.length > MAX_MSG_LEN) { req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); return rlog.warn('Refus: Trop long'), res.status(400).json({ error: `Max ${MAX_MSG_LEN} caractères` }); } const cleanContent = sanitizeHtml(content, { allowedTags: [], allowedAttributes: {}, disallowedTagsMode: 'discard' }).trim(); if (cleanContent) { const mod = await moderateContent(cleanContent); if (!mod.safe) { req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); return rlog.alert('🤖 MODÉRATION BLOQUÉE', { reason: mod.reason, model: mod.model }), res.status(400).json({ error: 'Message refusé par la modération automatique.', reason: mod.reason }); } rlog.debug('Modération OK', { model: mod.model }); } const insertMsg = db.prepare('INSERT INTO messages (link_id, ip_hash, content) VALUES (?, ?, ?)'); const insertImg = db.prepare('INSERT INTO images (message_id, filename, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?)'); const upsertRl = db.prepare(`INSERT INTO rate_limits (link_id, ip_hash, count, window_start) VALUES (?, ?, 1, ?) ON CONFLICT(link_id, ip_hash, window_start) DO UPDATE SET count = count + 1`); try { const msgId = db.transaction(() => { const info = insertMsg.run(linkId, ipHash, cleanContent); const mid = info.lastInsertRowid; if (req.files?.length) req.files.forEach(f => insertImg.run(mid, f.originalname, f.filename, f.mimetype, f.size)); upsertRl.run(linkId, ipHash, windowStart); return mid; })(); rlog.info('✅ Message SAUVÉ', { msgId, hasImages: !!req.files?.length, preview: cleanContent.slice(0, 50) }); res.status(201).json({ success: true, messageId: msgId }); } catch (e) { rlog.error('Erreur Transaction DB', { err: e.message, stack: e.stack }); req.files?.forEach(f => { try { unlinkSync(f.path) } catch(e) {} }); res.status(500).json({ error: 'Erreur sauvegarde' }); } }); // ========================================== // 🛠️ ADMIN ROUTES (Owner + Collaborateurs) // ========================================== app.get('/api/admin/links/:id/messages', authMiddleware, linkAccessMiddleware(['owner', 'moderator', 'viewer']), (req, res) => { const rlog = req.log.child({ route: 'ADMIN_MESSAGES', linkId: req.linkAccess.linkId, role: req.linkAccess.role }); const msgs = db.prepare(`SELECT m.id, m.content, m.created_at, m.ip_hash, (SELECT json_group_array(json_object('id', i.id, 'url', '/uploads/' || i.stored_name, 'filename', i.filename)) FROM images i WHERE i.message_id = m.id) as images FROM messages m WHERE m.link_id = ? ORDER BY m.created_at DESC LIMIT 200`).all(req.linkAccess.linkId); res.json({ messages: msgs.map(m => ({ ...m, images: m.images ? JSON.parse(m.images) : [] })), myRole: req.linkAccess.role }); }); app.post('/api/admin/links/:id/ban', authMiddleware, linkAccessMiddleware(['owner', 'moderator']), (req, res) => { const rlog = req.log.child({ route: 'BAN_IP', linkId: req.linkAccess.linkId }); if (!req.body.ip_hash) return res.status(400).json({ error: 'ip_hash requis' }); db.prepare('INSERT OR IGNORE INTO bans (link_id, ip_hash) VALUES (?, ?)').run(req.linkAccess.linkId, req.body.ip_hash); rlog.alert('🚫 IP Bannie par collaborateur', { ipHash: req.body.ip_hash, by: req.user.username, role: req.linkAccess.role }); res.json({ success: true }); }); app.delete('/api/admin/links/:id/ban', authMiddleware, linkAccessMiddleware(['owner', 'moderator']), (req, res) => { const rlog = req.log.child({ route: 'UNBAN_IP', linkId: req.linkAccess.linkId }); if (!req.body.ip_hash) return res.status(400).json({ error: 'ip_hash requis' }); db.prepare('DELETE FROM bans WHERE link_id = ? AND ip_hash = ?').run(req.linkAccess.linkId, req.body.ip_hash); rlog.info('✅ IP Débannie', { ipHash: req.body.ip_hash }); res.json({ success: true }); }); // ========================================== // 🚫 404 & ERROR HANDLER // ========================================== app.use((req, res) => { if (req.path.startsWith('/api/') || req.path.startsWith('/uploads/')) return res.status(404).json({ error: 'Not Found' }); res.status(404).sendFile(join(__dirname, 'public', '404.html'), (err) => { if(err) res.status(404).send('404') }); }); app.use((err, req, res, next) => { const rlog = req.log?.child({ route: 'ERROR_HANDLER' }) || log.child({ route: 'ERROR_HANDLER' }); if (err instanceof multer.MulterError) return res.status(err.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? `Fichier trop lourd (max ${MAX_FILE_SIZE/1e6}Mo)` : `Max ${MAX_FILES} fichiers` }); if (err.message.includes('Format interdit')) return res.status(400).json({ error: err.message }); rlog.fatal('💥 CRASH SERVEUR', { err: err.message, stack: err.stack }); res.status(500).json({ error: 'Erreur interne' }); }); // ========================================== // 🚀 START // ========================================== app.listen(PORT, '0.0.0.0', () => { log.info('🚀 SERVER READY', { port: PORT, env: NODE_ENV, db: DB_PATH, uploads: UPLOAD_DIR, bcryptCost: BCRYPT_COST }); if(!GROQ_API_KEY) log.warn('⚠️ Modération IA DÉSACTIVÉE (GROQ_API_KEY manquant)'); });