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 `
- ${prev}
- Page ${page} sur ${totalPages}
- ${next}
`;
}
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'), {
setHeaders: (res, path) => {
if (path.endsWith('.css')) {
res.setHeader('Content-Type', 'text/css');
}
}
}));
// Servez aussi les vendors sous /autopost
const tailwindPath = path.join(__dirname, '/node_modules/@tailwindcss/browser/dist');
const jqueryPath = path.join(__dirname, '/node_modules/jquery/dist');
console.log('[STATIC] Tailwind path:', tailwindPath, '- exists:', fs.existsSync(tailwindPath));
console.log('[STATIC] jQuery path:', jqueryPath, '- exists:', fs.existsSync(jqueryPath));
app.use('/js', express.static(
tailwindPath,
{ setHeaders: (res, filepath) => {
console.log('[STATIC] /js serving:', filepath);
if (filepath.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
}}
));
app.use('/jquery', express.static(
jqueryPath,
{ setHeaders: (res, filepath) => {
console.log('[STATIC] /jquery serving:', filepath);
if (filepath.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
}}
));
// Miroir des vendors sous /autopost (si reverse-proxy ne forwarde que /autopost/*)
app.use('/autopost/js', express.static(
tailwindPath,
{ setHeaders: (res, filepath) => {
console.log('[STATIC] /autopost/js serving:', filepath);
if (filepath.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
}}
));
app.use('/autopost/jquery', express.static(
jqueryPath,
{ setHeaders: (res, filepath) => {
console.log('[STATIC] /autopost/jquery serving:', filepath);
if (filepath.endsWith('.js')) {
res.setHeader('Content-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 = ``;
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`);
});