const express = require('express'); const session = require('express-session'); const FileStore = require('session-file-store')(session); const path = require('path'); const fs = require('fs'); const AnsiToHtml = require('ansi-to-html'); const convert = new AnsiToHtml(); const { exec } = require('child_process'); const os = require('os'); const crypto = require('crypto'); // CSRF const argon2 = require('argon2'); // Pour l'authentification DB const config = require('./config'); const db = require('./db'); const chokidar = require('chokidar'); db.testConnection(); // vérification au démarrage function resolveTrustProxy(v) { if (v == null) return 0; if (v === true || v === 'true' || v === 'all') return true; if (typeof v === 'number' || /^\d+$/.test(String(v))) return Number(v); if (Array.isArray(v)) return v; return String(v); // ex: "loopback,uniquelocal,127.0.0.1/8" } // -------- Helpers sécurité / robustesse -------- function esc(s) { return String(s).replace(/[&<>"'`=]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`', '=': '=' }[c])); } function safeBaseName(input) { if (typeof input !== 'string') return null; const onlyName = path.basename(input); if (onlyName !== input) return null; if (!/^[\w .\-()[\]]{1,200}$/.test(onlyName)) return null; return path.parse(onlyName).name; // sans extension } function clamp(n, min, max) { n = parseInt(n, 10); if (isNaN(n)) n = min; return Math.max(min, Math.min(max, n)); } // -------- Mini moteur de template (fichier HTML avec placeholders) -------- const DEV = process.env.NODE_ENV !== 'production'; const TEMPLATE_AUTPOST_PATH = path.join(__dirname, 'views', 'autopost.html'); let _tplAutopostCache = null; function getAutopostTemplate() { if (DEV) { return fs.readFileSync(TEMPLATE_AUTPOST_PATH, 'utf8'); } if (!_tplAutopostCache) { _tplAutopostCache = fs.readFileSync(TEMPLATE_AUTPOST_PATH, 'utf8'); } return _tplAutopostCache; } // Remplace {{PLACEHOLDER}} par data[PLACEHOLDER] function renderTemplate(tpl, data) { return tpl.replace(/{{\s*([A-Z0-9_]+)\s*}}/g, (_, k) => { return Object.prototype.hasOwnProperty.call(data, k) ? String(data[k]) : ''; }); } function renderRow(row) { let statusText = ''; let statusClass = ''; switch (parseInt(row.status)) { case 0: statusText = 'EN ATTENTE'; statusClass = 'bg-cyan-500 text-black font-bold'; break; case 1: statusText = 'ENVOI TERMINÉ'; statusClass = 'bg-green-300 text-black font-bold'; break; case 2: statusText = 'ERREUR'; statusClass = 'bg-red-300 text-black font-bold'; break; case 3: statusText = 'DEJA DISPONIBLE'; statusClass = 'bg-pink-300 text-black font-bold'; break; case 4: statusText = 'EN COURS'; statusClass = 'bg-yellow-300 text-black font-bold'; break; default: statusText = 'INCONNU'; } const logLink = (parseInt(row.status) === 1 || parseInt(row.status) === 2 || parseInt(row.status) === 4) ? ' | Log' : ''; const mediainfoLink = (parseInt(row.status) === 0 || parseInt(row.status) === 1 || parseInt(row.status) === 2) ? ' | MediaInfo/BDInfo' : ''; const dlLink = (parseInt(row.status) === 1) ? ' | DL' : ''; const resendLink = (parseInt(row.status) === 1) ? ' | Renvoyer' : ''; return ` ${esc(row.nom)} ${statusText} ${row.id} Editer | Supprimer ${logLink}${mediainfoLink}${dlLink}${resendLink} `; } function renderPaginationHTML(page, totalPages) { const prevDisabled = page <= 1; const nextDisabled = page >= totalPages; const prev = prevDisabled ? `Précédent` : `Précédent`; const next = nextDisabled ? `Suivant` : `Suivant`; return ` `; } const app = express(); const port = config.port; const background_color = (config?.background_color ?? '').trim() || 'slate-900'; app.disable('x-powered-by'); // header express off app.use(express.json()); // utile si un jour POST JSON // Middleware pour parser les formulaires POST app.use(express.urlencoded({ extended: true })); app.set('trust proxy', resolveTrustProxy(config.trustProxy)); /* --- Session 7 jours, expiration glissante --- */ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const SEVEN_DAYS_S = Math.floor(SEVEN_DAYS_MS / 1000); app.use(session({ store: new FileStore({ path: config.sessionStorePath || './sessions', ttl: SEVEN_DAYS_S, // côté store (secondes) retries: 0 }), secret: config.sessionSecret, resave: false, saveUninitialized: false, rolling: true, // renouvelle à chaque requête cookie: { maxAge: SEVEN_DAYS_MS, // côté navigateur (ms) sameSite: 'lax', secure: !!config.cookieSecure, // true seulement si HTTPS } })); app.use(express.static('public')); const autopostRouter = express.Router(); // Servez /public à la racine ET sous /autopost (utile si l'app est proxifiée sous /autopost) app.use(express.static(path.join(__dirname, 'public'))); app.use('/autopost', express.static(path.join(__dirname, 'public'))); // Servez aussi les vendors sous /autopost app.use('/js', express.static( path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'), { setHeaders: (res) => res.type('application/javascript') } )); app.use('/jquery', express.static( path.join(__dirname, '../node_modules/jquery/dist'), { setHeaders: (res) => res.type('application/javascript') } )); // Miroir des vendors sous /autopost (si reverse-proxy ne forwarde que /autopost/*) app.use('/autopost/js', express.static( path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'), { setHeaders: (res) => res.type('application/javascript') } )); app.use('/autopost/jquery', express.static( path.join(__dirname, '../node_modules/jquery/dist'), { setHeaders: (res) => res.type('application/javascript') } )); // --------------------------- Auth non protégée ----------------------------- autopostRouter.get('/login', (req, res) => { res.send(` Login ${esc(config.name)}

Authentification

`); }); autopostRouter.post('/login', async (req, res) => { const { username, password } = req.body; console.log(`[LOGIN] Tentative de connexion pour "${username}"`); console.log(`[LOGIN] Password reçu: longueur ${password ? password.length : 0} caractères`); // 1. Essayer d'abord l'authentification via la base de données try { console.log(`[LOGIN] Tentative d'authentification DB pour "${username}"`); const [rows] = await db.query( 'SELECT * FROM core_members WHERE name = ? AND member_group_id IN (1,2) LIMIT 1', [username] ); console.log(`[LOGIN] Nombre de résultats DB: ${rows.length}`); if (rows.length > 0) { const member = rows[0]; console.log(`[LOGIN] Utilisateur trouvé en DB:`, { member_id: member.member_id, name: member.name, group: member.member_group_id }); // Vérification du hash Argon2id console.log(`[LOGIN] Vérification du mot de passe avec Argon2id`); const valid = await argon2.verify(member.members_pass_hash, password); console.log(`[LOGIN] Résultat vérification Argon2id: ${valid}`); if (valid) { console.log(`[LOGIN] ✅ Authentification DB réussie pour "${member.name}"`); req.session.authenticated = true; req.session.user_id = member.member_id; req.session.user_name = member.name; return res.redirect('/autopost'); } else { console.warn(`[LOGIN] ❌ Mot de passe DB incorrect pour "${member.name}"`); } } else { console.log(`[LOGIN] Aucun utilisateur trouvé en DB pour "${username}"`); } } catch (err) { console.error(`[LOGIN] Erreur DB:`, err); // Continue vers le fallback config } // 2. Fallback : authentification via fichier config console.log(`[LOGIN] Tentative d'authentification config pour "${username}"`); if (username === config.auth.username && password === config.auth.password) { console.log(`[LOGIN] ✅ Authentification config réussie pour "${username}"`); req.session.authenticated = true; req.session.user_name = username; return res.redirect('/autopost'); } else { console.log(`[LOGIN] ❌ Authentification config échouée pour "${username}"`); } // 3. Échec des deux méthodes console.warn(`[LOGIN] 🚫 Échec total d'authentification pour "${username}"`); res.redirect('login?e=1'); }); autopostRouter.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/autopost/login')); }); // --------------------------- Favicon dynamique ----------------------------- autopostRouter.get('/favicon.ico', (req, res) => { const initials = config.name.substring(0, 2).toUpperCase(); const svg = ` ${initials} `; res.setHeader('Content-Type', 'image/svg+xml'); res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache 24h res.send(svg); }); function checkAuth(req, res, next) { if (req.session && req.session.authenticated) { next(); } else { res.redirect(req.baseUrl + '/login'); } } autopostRouter.use(checkAuth); // CSRF : générer un token pour les vues protégées autopostRouter.use((req, res, next) => { if (!req.session.csrf) req.session.csrf = crypto.randomUUID(); next(); }); // CSRF : exiger le token sur POST (sauf /login qui est défini avant) function requireCsrf(req, res, next) { if (req.method === 'POST') { const t = req.get('x-csrf-token'); if (!t || !req.session || t !== req.session.csrf) { return res.status(403).send('CSRF token invalide'); } } next(); } autopostRouter.use(requireCsrf); // ---- Long-polling refresh (robuste) ---- let lpVersion = 1; const lpWaiters = new Set(); const LP_TIMEOUT_MS = 25000; function lpNotify(source, filePath) { lpVersion += 1; //console.log('[LP] notify', { version: lpVersion, source, file: filePath }); for (const waiter of lpWaiters) { clearTimeout(waiter.timer); try { waiter.res.json({ version: lpVersion }); } catch (_) {} } lpWaiters.clear(); } // Endpoint long-polling autopostRouter.get('/updates', (req, res) => { const since = parseInt(req.query.since, 10) || 0; if (lpVersion > since) { return res.json({ version: lpVersion }); } const waiter = { res, timer: setTimeout(() => { lpWaiters.delete(waiter); res.json({ version: lpVersion }); // heartbeat/timeout }, LP_TIMEOUT_MS) }; lpWaiters.add(waiter); req.on('close', () => { clearTimeout(waiter.timer); lpWaiters.delete(waiter); }); }); // Watcher fichiers (JSON / BDINFO / LOG) + fallback scan const infoDir = path.resolve(config.infodirectory || config.logdirectory || '.'); const logDir = path.resolve(config.logdirectory || config.infodirectory || '.'); const watchPatterns = Array.from(new Set([ path.join(infoDir, '*.json'), path.join(infoDir, '*.JSON'), path.join(infoDir, '*.bdinfo.txt'), path.join(infoDir, '*.BDINFO.TXT'), path.join(logDir, '*.log') ])); const lpWatcher = chokidar.watch(watchPatterns, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 1200, pollInterval: 250 }, usePolling: true, // important pour NFS/SMB/Docker interval: 1000, depth: 0, ignorePermissionErrors: true, persistent: true }); lpWatcher.on('add', (filePath) => lpNotify('add', filePath)); lpWatcher.on('change', (filePath) => lpNotify('change', filePath)); //lpWatcher.on('ready', () => console.log('[LP] watcher ready on', watchPatterns)); lpWatcher.on('error', (err) => console.error('[LP] watcher error:', err)); // Fallback scan (toutes les 2s) : signature (nb fichiers + dernier mtime) const fsp = fs.promises; // réutilise ton import fs existant let lastSig = null; async function computeSignature() { async function listSig(dir, rx) { try { const names = await fsp.readdir(dir); const files = names.filter(n => rx.test(n)); let latest = 0; await Promise.all(files.map(async (n) => { try { const st = await fsp.stat(path.join(dir, n)); if (st.mtimeMs > latest) latest = st.mtimeMs; } catch (_) {} })); return { count: files.length, latest }; } catch { return { count: 0, latest: 0 }; } } const a = await listSig(infoDir, /(?:\.json|\.bdinfo\.txt)$/i); const b = await listSig(logDir, /(?:\.log)$/i); const files = a.count + b.count; const latest = Math.max(a.latest, b.latest); return files + ':' + Math.floor(latest); } async function periodicScan() { try { const sig = await computeSignature(); if (lastSig === null) { lastSig = sig; // baseline } else if (sig !== lastSig) { lastSig = sig; lpNotify('scan', infoDir + '|' + logDir); } } catch (e) { console.error('[LP] periodic scan error:', e.message || e); } finally { setTimeout(periodicScan, 2000); } } periodicScan(); // --------------------------- PAGE PRINCIPALE ----------------------------- autopostRouter.get('/', async (req, res) => { const limit = 100; // enregistrements par page const page = clamp(req.query.page, 1, 1_000_000); const offset = (page - 1) * limit; try { const [stats] = await db.query(` SELECT COUNT(*) AS total, SUM(status = 0) AS attente, SUM(status = 1) AS termine, SUM(status = 2) AS erreur, SUM(status = 3) AS deja, SUM(status = 4) AS encours FROM \`${config.DB_TABLE}\` `); // Récupérer le nombre total d'enregistrements const [countResult] = await db.query(`SELECT COUNT(*) as total FROM \`${config.DB_TABLE}\``); const totalRecords = countResult[0].total; const totalPages = Math.max(1, Math.ceil(totalRecords / limit)); const [rows] = await db.query( `SELECT nom, status, id FROM \`${config.DB_TABLE}\` ORDER BY (status = 2) DESC, id DESC LIMIT ? OFFSET ?`, [limit, offset] ); const tableRowsHTML = rows.map(renderRow).join('\n'); const paginationHTML = renderPaginationHTML(page, totalPages); // JSON d’amorçage pour le client const BOOTSTRAP_JSON = JSON.stringify({ csrf: req.session.csrf, page, totalPages, limit }).replace(/ const tpl = getAutopostTemplate(); const html = renderTemplate(tpl, { TITLE: `Suivi Autopost ${esc(config.name)}`, CSRF_TOKEN: esc(req.session.csrf), BACKGROUND_CLASS: background_color, // ex: slate-900 APP_NAME: esc(config.name), STAT_ATTENTE: stats[0].attente || 0, STAT_TERMINE: stats[0].termine || 0, STAT_ERREUR: stats[0].erreur || 0, STAT_DEJA: stats[0].deja || 0, PAGINATION_HTML: paginationHTML, TABLE_ROWS: tableRowsHTML, BOOTSTRAP_JSON }); res.send(html); } catch (err) { console.error(err.message); res.status(500).send("Erreur lors de la requête DB."); } }); // --------------------------- Recherche ----------------------------- autopostRouter.get('/search', async (req, res) => { const q = String(req.query.q || "").slice(0, 200); const page = clamp(req.query.page, 1, 1_000_000); const limit = clamp(req.query.limit, 1, 500) || 100; const offset = (page - 1) * limit; const searchQuery = `%${q}%`; try { const [countRows] = await db.query( `SELECT COUNT(*) AS total FROM \`${config.DB_TABLE}\` WHERE nom LIKE ?`, [searchQuery] ); const total = countRows[0].total; const totalPages = Math.max(1, Math.ceil(total / limit)); const [rows] = await db.query( `SELECT nom, status, id FROM \`${config.DB_TABLE}\` WHERE nom LIKE ? ORDER BY (status = 2) DESC, id DESC LIMIT ? OFFSET ?`, [searchQuery, limit, offset] ); res.json({ rows, page, limit, total, totalPages }); } catch (err) { console.error(err.message); res.status(500).json({ error: "Erreur lors de la requête." }); } }); autopostRouter.get('/log', (req, res) => { const base = safeBaseName(req.query.name); if (!base) { return res.status(400).json({ error: "Nom de fichier invalide." }); } const logFilePath = path.join(config.logdirectory, base + '.log'); fs.readFile(logFilePath, 'utf8', (err, data) => { if (err) { console.error(err.message); return res.status(500).json({ error: "Erreur lors du chargement du fichier log." }); } // 1) enlever les codes curseur (début de ligne/clear) mais garder les couleurs (…m) let s = data.replace(/\x1b\[[0-9;]*[GK]/g, ''); // 2) supprimer TOUT segment de progression const ANSI_M = '(?:\\x1b\\[[0-9;]*m)*'; const NUM = '\\d{1,3}(?:\\.\\d+)?'; const PROG = new RegExp( ANSI_M + '\\s*' + NUM + '\\s*%' + '\\s*' + ANSI_M + '\\[[^\\]]*\\]' + '(?:\\s*' + ANSI_M + NUM + '\\s*MiB\\/s,\\s*ETA\\s*[^\\x1bG\\n]*)?', 'g' ); s = s.replace(PROG, ''); // 3) nettoyer les résidus "-G" / "G" avant G[INFO] s = s.replace(/-?\s*G(?=\[INFO\])/g, ''); // 4) normalisation s = s.replace(/[ \t]+/g, ' ') .replace(/\r/g, '') .replace(/\n{3,}/g, '\n\n') .replace(/(?!^)(?=\[INFO\])/gm, '\n') .replace(/(?!^)Calculating\s*:/gm, '\nCalculating :') .replace(/(?!^)Writing\s*:/gm, '\nWriting :') .replace(/(?!^)Finalizing\s*:/gm, '\nFinalizing :') .replace(/(?!^)Finished\s*:/gm, '\nFinished :') .trim(); const htmlContent = convert.toHtml(s); res.json({ content: htmlContent }); }); }); // --------------------------- Mediainfo ----------------------------- autopostRouter.get('/mediainfo', (req, res) => { const base = safeBaseName(req.query.name); if (!base) { return res.status(400).json({ error: "Nom de fichier invalide." }); } const jsonPath = path.join(config.infodirectory, base + '.json'); const bdinfoPath = path.join(config.infodirectory, base + '.bdinfo.txt'); // Vérifie lequel des deux existe, priorité au .json fs.access(jsonPath, fs.constants.F_OK, (errJson) => { if (!errJson) { // .json existe readAndSend(jsonPath); } else { // .json n'existe pas, on essaie .bdinfo.txt fs.access(bdinfoPath, fs.constants.F_OK, (errBdinfo) => { if (!errBdinfo) { readAndSend(bdinfoPath); } else { res.status(404).json({ error: "Aucun fichier mediainfo trouvé." }); } }); } }); function readAndSend(filePath) { fs.readFile(filePath, 'utf8', (err, data) => { if (err) { console.error(err.message); return res.status(500).json({ error: "Erreur lors du chargement du fichier mediainfo." }); } // Essaie de prettifier si JSON try { const obj = JSON.parse(data); const pretty = JSON.stringify(obj, null, 2); res.json({ content: pretty }); } catch (e) { res.json({ content: data }); } }); } }); // --------------------------- Download ----------------------------- autopostRouter.get('/dl', (req, res) => { const base = safeBaseName(req.query.name); if (!base) { return res.status(400).send("Nom de fichier invalide."); } const subfolder = base.charAt(0).toUpperCase(); const archiveFilePath = path.join(config.finishdirectory, subfolder, base + '.7z'); console.log("Tentative de téléchargement (archive 7z) :", archiveFilePath); const tmpDir = path.join(__dirname, 'tmp'); fs.mkdirSync(tmpDir, { recursive: true }); // s'assurer que le répertoire existe const extractedFilePath = path.join(tmpDir, base + '.nzb'); const command = `7z e "${archiveFilePath}" -o"${tmpDir}" -y`; exec(command, (error, stdout, stderr) => { if (error) { console.error(`Erreur lors de la décompression: ${error.message}`); return res.status(500).send("Erreur lors de la décompression."); } res.download(extractedFilePath, base + '.nzb', (err) => { if (err) { console.error("Erreur lors du téléchargement :", err); res.status(500).send("Erreur lors du téléchargement."); } fs.unlink(extractedFilePath, (err) => { if (err) { console.error("Erreur lors de la suppression du fichier temporaire :", err); } }); }); }); }); // --------------------------- Édition ----------------------------- autopostRouter.post('/edit/:id', async (req, res) => { const id = req.params.id; const newStatus = req.body.status; try { await db.query(`UPDATE \`${config.DB_TABLE}\` SET status = ? WHERE id = ?`, [newStatus, id]); if (req.xhr || req.get('accept')?.includes('json')) { res.json({ success: true }); } else { res.redirect("/autopost/"); } } catch (err) { console.error(err.message); res.status(500).send("Erreur lors de la mise à jour."); } }); // --------------------------- Suppression ----------------------------- autopostRouter.post('/delete/:id', async (req, res) => { const id = req.params.id; try { await db.query(`DELETE FROM \`${config.DB_TABLE}\` WHERE id = ?`, [id]); if (req.xhr || req.get('accept')?.includes('json')) { res.json({ success: true }); } else { res.redirect("/autopost/"); } } catch (err) { console.error(err.message); res.status(500).json({ error: "Erreur lors de la suppression." }); } }); // --------------------------- Opérations en lot ----------------------------- // Édition en lot autopostRouter.post('/bulk-edit', async (req, res) => { const { ids, status } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'Liste d\'IDs invalide' }); } const statusInt = parseInt(status, 10); if (![0, 1, 2, 3, 4].includes(statusInt)) { return res.status(400).json({ error: 'Statut invalide' }); } // Valider que tous les IDs sont des entiers positifs const validIds = ids.filter(id => parseInt(id, 10) > 0).map(id => parseInt(id, 10)); if (validIds.length === 0) { return res.status(400).json({ error: 'Aucun ID valide' }); } try { const placeholders = validIds.map(() => '?').join(','); const query = `UPDATE \`${config.DB_TABLE}\` SET status = ? WHERE id IN (${placeholders})`; const params = [statusInt, ...validIds]; const [result] = await db.query(query, params); res.json({ message: `${result.affectedRows} élément(s) mis à jour`, updated: result.affectedRows }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Erreur DB lors de la mise à jour en lot' }); } }); // Suppression en lot autopostRouter.post('/bulk-delete', async (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'Liste d\'IDs invalide' }); } // Valider que tous les IDs sont des entiers positifs const validIds = ids.filter(id => parseInt(id, 10) > 0).map(id => parseInt(id, 10)); if (validIds.length === 0) { return res.status(400).json({ error: 'Aucun ID valide' }); } try { const placeholders = validIds.map(() => '?').join(','); const query = `DELETE FROM \`${config.DB_TABLE}\` WHERE id IN (${placeholders})`; const [result] = await db.query(query, validIds); res.json({ message: `${result.affectedRows} élément(s) supprimé(s)`, deleted: result.affectedRows }); } catch (err) { console.error(err.message); res.status(500).json({ error: 'Erreur DB lors de la suppression en lot' }); } }); // --------------------------- Renvoi ----------------------------- autopostRouter.post('/resend/:id', async (req, res) => { const id = req.params.id; try { // Récupérer les informations de l'enregistrement const [rows] = await db.query(`SELECT nom, status FROM \`${config.DB_TABLE}\` WHERE id = ?`, [id]); if (rows.length === 0) { return res.status(404).json({ error: "Enregistrement non trouvé." }); } const row = rows[0]; const fileName = row.nom; const status = parseInt(row.status); // Vérifier que le statut est "ENVOI TERMINÉ" (status = 1) if (status !== 1) { return res.status(400).json({ error: "Seuls les enregistrements avec statut 'ENVOI TERMINÉ' peuvent être renvoyés." }); } const base = safeBaseName(fileName); if (!base) { return res.status(400).json({ error: "Nom de fichier invalide." }); } // Chemins des fichiers nécessaires const subfolder = base.charAt(0).toUpperCase(); const nzbArchivePath = path.join(config.finishdirectory, subfolder, base + '.7z'); const jsonPath = path.join(config.infodirectory, base + '.json'); const bdinfoPath = path.join(config.infodirectory, base + '.bdinfo.txt'); const quicksummaryPath = path.join(config.infodirectory, base + '.quicksummary.txt'); // Vérifier que l'archive NZB existe if (!fs.existsSync(nzbArchivePath)) { return res.status(404).json({ error: "Archive NZB non trouvée." }); } // Extraire le NZB temporairement const tmpDir = path.join(__dirname, 'tmp'); fs.mkdirSync(tmpDir, { recursive: true }); const tmpNzbPath = path.join(tmpDir, base + '.nzb'); const extractCommand = `7z e "${nzbArchivePath}" -o"${tmpDir}" -y`; exec(extractCommand, (extractError) => { if (extractError) { console.error(`Erreur lors de l'extraction: ${extractError.message}`); return res.status(500).json({ error: "Erreur lors de l'extraction du NZB." }); } // Vérifier si c'est un ISO (BDInfo) ou autre (JSON) const isIso = fileName.toLowerCase().endsWith('.iso'); let curlCommand; if (isIso) { // Pour les ISO : bdinfo_full + bdinfo_mini + nzb if (!fs.existsSync(bdinfoPath) || !fs.existsSync(quicksummaryPath)) { fs.unlinkSync(tmpNzbPath); return res.status(404).json({ error: "Fichiers BDInfo non trouvés." }); } curlCommand = `curl -s -k -L -m 60 \\ -F rlsname=${base} \\ -F bdinfo_full=@${bdinfoPath} \\ -F bdinfo_mini=@${quicksummaryPath} \\ -F nzb=@${tmpNzbPath} \\ -F upload=upload "${config.apiUrl}${config.apiKey}"`; } else { // Pour les autres : generated_nfo_json + nzb if (!fs.existsSync(jsonPath)) { fs.unlinkSync(tmpNzbPath); return res.status(404).json({ error: "Fichier JSON non trouvé." }); } curlCommand = `curl -s -k -L -m 60 \\ -F rlsname=${base} \\ -F generated_nfo_json=@${jsonPath} \\ -F nzb=@${tmpNzbPath} \\ -F upload=upload "${config.apiUrl}${config.apiKey}"`; } // Exécuter la commande curl exec(curlCommand, (curlError, stdout, stderr) => { // Nettoyer le fichier temporaire fs.unlinkSync(tmpNzbPath); // Log des sorties curl pour debug console.log(`=== CURL OUTPUT pour ${fileName} ===`); console.log(`Command: ${curlCommand}`); if (stdout) console.log(`STDOUT: ${stdout}`); if (stderr) console.log(`STDERR: ${stderr}`); console.log(`================================`); if (curlError) { console.error(`Erreur lors du renvoi: ${curlError.message}`); return res.status(500).json({ error: "Erreur lors du renvoi vers le site principal." }); } console.log(`Renvoi réussi pour ${fileName}`); res.json({ success: true, message: "Renvoi effectué avec succès." }); }); }); } catch (err) { console.error(err.message); res.status(500).json({ error: "Erreur lors du renvoi." }); } }); // --------------------------- Filtrage ----------------------------- autopostRouter.get('/filter', async (req, res) => { const status = Number.isFinite(parseInt(req.query.status, 10)) ? clamp(req.query.status, 0, 4) : null; if (status === null) { return res.status(400).json({ error: "Status non fourni." }); } const page = clamp(req.query.page, 1, 1_000_000); const limit = clamp(req.query.limit, 1, 500) || 100; const offset = (page - 1) * limit; try { const [countRows] = await db.query( `SELECT COUNT(*) AS total FROM \`${config.DB_TABLE}\` WHERE status = ?`, [status] ); const total = countRows[0].total; const totalPages = Math.max(1, Math.ceil(total / limit)); const [rows] = await db.query( `SELECT nom, status, id FROM \`${config.DB_TABLE}\` WHERE status = ? ORDER BY id DESC LIMIT ? OFFSET ?`, [status, limit, offset] ); res.json({ rows, page, limit, total, totalPages }); } catch (err) { console.error(err.message); res.status(500).json({ error: "Erreur lors de la requête." }); } }); // --------------------------- STATS ----------------------------- autopostRouter.get('/stats', async (req, res) => { try { const [rows] = await db.query(` SELECT COUNT(*) AS total, SUM(status = 0) AS attente, SUM(status = 1) AS termine, SUM(status = 2) AS erreur, SUM(status = 3) AS deja, SUM(status = 4) AS encours FROM \`${config.DB_TABLE}\` `); res.json(rows[0]); } catch (e) { console.error(e); res.status(500).json({ error: 'Erreur stats' }); } }); // Redirection accès direct GET /edit/:id autopostRouter.get('/edit/:id', (req, res) => { res.redirect("/autopost/"); }); app.use('/autopost', autopostRouter); // Endpoint santé simple app.get('/healthz', (req, res) => res.type('text').send('ok')); app.listen(port, () => { console.log(`Serveur démarré sur http://localhost:${port}/autopost`); });