Spaces:
Running
Running
| // 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)'); | |
| }); |