File size: 32,833 Bytes
7ac6237
d7e34bc
2c9497a
4b2790e
 
 
42581ec
dd386f9
 
 
 
 
 
 
 
 
 
4b2790e
dd386f9
c7c0914
42581ec
 
 
 
 
 
 
 
2c9497a
 
 
42581ec
 
 
2c9497a
42581ec
 
 
 
 
 
 
2c9497a
42581ec
2c9497a
42581ec
 
 
 
 
 
 
 
 
 
 
 
 
 
c7c0914
4b2790e
 
 
 
 
 
a6e609b
 
4b2790e
97365de
a6e609b
79cb47f
 
 
a6e609b
 
42581ec
 
4b2790e
d7e34bc
4b2790e
42581ec
 
 
 
a6e609b
79cb47f
a6e609b
42581ec
 
 
a6e609b
42581ec
a6e609b
 
42581ec
d7e34bc
42581ec
4b2790e
dd386f9
4b2790e
79cb47f
4b2790e
e95e7e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7e34bc
 
 
 
 
 
 
 
 
 
 
 
e95e7e6
 
 
 
 
4b2790e
d7e34bc
4b2790e
42581ec
d7e34bc
42581ec
4b2790e
 
d7e34bc
4b2790e
2c9497a
4b2790e
97365de
2c9497a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b2790e
 
d7e34bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42581ec
2c9497a
42581ec
4b2790e
d7e34bc
2c9497a
 
 
 
 
d7e34bc
2c9497a
d7e34bc
2c9497a
 
 
 
 
 
 
 
d7e34bc
2c9497a
 
4b2790e
dd386f9
 
4b2790e
 
 
42581ec
2c9497a
42581ec
 
 
 
 
 
 
 
 
 
4b2790e
dd386f9
42581ec
7ac6237
42581ec
7ac6237
42581ec
97365de
42581ec
97365de
 
a6e609b
42581ec
97365de
42581ec
97365de
42581ec
 
7ac6237
d7e34bc
 
42581ec
 
 
 
97365de
2c9497a
42581ec
97365de
42581ec
97365de
 
42581ec
2c9497a
42581ec
2c9497a
 
 
 
 
 
 
 
 
 
 
 
 
 
4b2790e
2c9497a
d7e34bc
2c9497a
 
 
 
d7e34bc
2c9497a
 
 
d7e34bc
2c9497a
 
 
d7e34bc
2c9497a
 
 
 
 
4b2790e
42581ec
d7e34bc
42581ec
2c9497a
42581ec
4b2790e
42581ec
2c9497a
42581ec
4b2790e
d7e34bc
a6e609b
4b2790e
a6e609b
42581ec
4b2790e
 
2c9497a
42581ec
4b2790e
 
42581ec
2c9497a
42581ec
4b2790e
 
d7e34bc
2c9497a
 
 
 
d7e34bc
2c9497a
4b2790e
a6e609b
42581ec
4b2790e
 
42581ec
2c9497a
 
 
 
 
 
a6e609b
4b2790e
42581ec
d7e34bc
42581ec
4b2790e
42581ec
d7e34bc
42581ec
4b2790e
 
42581ec
4b2790e
42581ec
4b2790e
42581ec
2c9497a
 
42581ec
2c9497a
 
42581ec
 
2c9497a
 
42581ec
4b2790e
42581ec
4b2790e
42581ec
 
2c9497a
42581ec
4b2790e
 
d7e34bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42581ec
 
 
4b2790e
a6e609b
 
4b2790e
 
 
42581ec
4b2790e
42581ec
4b2790e
a6e609b
4b2790e
97365de
 
a6e609b
97365de
42581ec
a6e609b
4b2790e
 
42581ec
2c9497a
42581ec
4b2790e
8acc7cf
4b2790e
2c9497a
42581ec
4b2790e
a6e609b
42581ec
4b2790e
2c9497a
4b2790e
a6e609b
97365de
2c9497a
4b2790e
 
42581ec
 
 
7ab86aa
42581ec
 
4b2790e
a6e609b
4b2790e
7ab86aa
 
2c9497a
7ab86aa
 
4b2790e
 
 
a6e609b
4b2790e
 
a6e609b
 
 
 
4b2790e
a6e609b
 
2c9497a
4b2790e
2c9497a
4b2790e
2c9497a
42581ec
d7e34bc
42581ec
d7e34bc
 
 
 
4b2790e
42581ec
d7e34bc
 
 
 
 
 
4b2790e
42581ec
d7e34bc
 
 
 
 
 
4b2790e
 
42581ec
2c9497a
42581ec
 
 
4b2790e
42581ec
2c9497a
a6e609b
42581ec
a6e609b
4b2790e
 
42581ec
 
 
4b2790e
2c9497a
 
4b2790e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
// 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)');
});