Anonymous-Messages / server.js
NathMen12's picture
Update server.js
7ac6237 verified
Raw
History Blame Contribute Delete
32.8 kB
// 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)');
});