1
0
postauto/autopost/server.js
2025-11-25 22:47:41 +01:00

1004 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// Vérification et création du dossier sessions si nécessaire
const sessionPath = path.resolve(__dirname, config.sessionStorePath || './sessions');
if (!fs.existsSync(sessionPath)) {
console.log(`[SESSION] Création du dossier sessions: ${sessionPath}`);
fs.mkdirSync(sessionPath, { recursive: true });
}
// Vérification de la configuration de session
if (!config.sessionSecret || config.sessionSecret === 'Voir commande ci dessus') {
console.warn('[SESSION] ⚠️ ATTENTION: sessionSecret non configuré ! Utilisez: curl -L pw.vdx.sh/w/32');
}
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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;', '`': '&#96;', '=': '&#61;'
}[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)
? ' | <a href="#" class="log-link text-blue-400 hover:underline" data-filename="' + esc(row.nom) + '">Log</a>'
: '';
const mediainfoLink = (parseInt(row.status) === 0 || parseInt(row.status) === 1 || parseInt(row.status) === 2)
? ' | <a href="#" class="mediainfo-link text-blue-400 hover:underline" data-filename="' + esc(row.nom) + '">MediaInfo/BDInfo</a>'
: '';
const dlLink = (parseInt(row.status) === 1)
? ' | <a href="/autopost/dl?name=' + encodeURIComponent(row.nom) + '" class="dl-link text-blue-400 hover:underline">DL</a>'
: '';
const resendLink = (parseInt(row.status) === 1)
? ' | <a href="#" class="resend-link text-green-400 hover:underline" data-id="' + row.id + '" data-filename="' + esc(row.nom) + '">Renvoyer</a>'
: '';
return `
<tr id="row-${row.id}" data-status="${row.status}" class="odd:bg-gray-800 even:bg-gray-700">
<td class="px-4 py-2 border border-gray-700 text-center">
<input type="checkbox" class="row-checkbox checkbox-custom" data-id="${row.id}" data-name="${esc(row.nom)}">
</td>
<td class="px-4 py-2 border border-gray-700">${esc(row.nom)}</td>
<td class="px-4 py-2 border border-gray-700 status-text whitespace-nowrap ${statusClass}">${statusText}</td>
<td class="px-4 py-2 border border-gray-700">${row.id}</td>
<td class="px-4 py-2 border border-gray-700 whitespace-nowrap">
<a href="#" class="edit-link text-blue-400 hover:underline" data-id="${row.id}" data-status="${row.status}">Editer</a> |
<a href="#" class="delete-link text-blue-400 hover:underline" data-id="${row.id}">Supprimer</a>
${logLink}${mediainfoLink}${dlLink}${resendLink}
</td>
</tr>`;
}
function renderPaginationHTML(page, totalPages) {
const prevDisabled = page <= 1;
const nextDisabled = page >= totalPages;
const prev = prevDisabled
? `<span class="px-3 py-2 ml-0 leading-tight text-gray-500 bg-gray-200 border border-gray-300 rounded-l-lg">Précédent</span>`
: `<a href="/autopost/?page=${page - 1}" data-page="${page - 1}" class="px-3 py-2 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-100 hover:text-gray-700">Précédent</a>`;
const next = nextDisabled
? `<span class="px-3 py-2 leading-tight text-gray-500 bg-gray-200 border border-gray-300 rounded-r-lg">Suivant</span>`
: `<a href="/autopost/?page=${page + 1}" data-page="${page + 1}" class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-100 hover:text-gray-700">Suivant</a>`;
return `
<ul class="inline-flex items-center -space-x-px">
<li>${prev}</li>
<li><span class="px-4 py-2 leading-tight text-gray-700 bg-white border border-gray-300">Page ${page} sur ${totalPages}</span></li>
<li>${next}</li>
</ul>`;
}
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(`
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Login ${esc(config.name)}</title>
<!-- Inclusion de Tailwind CSS via le CDN -->
<script src="/js/index.global.js" type="application/javascript"></script>
</head>
<body class="bg-${background_color} flex items-center justify-center min-h-screen">
<div class="bg-slate-700 p-8 rounded-lg shadow-md w-80">
<h2 class="text-center text-2xl font-bold text-white mb-4">Authentification</h2>
<form method="POST" action="/autopost/login">
<input type="text" name="username" placeholder="Identifiant" required
class="w-full p-2 mb-4 rounded border border-gray-300"/>
<input type="password" name="password" placeholder="Mot de passe" required
class="w-full p-2 mb-4 rounded border border-gray-300"/>
<button type="submit"
class="w-full p-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Se connecter
</button>
</form>
</div>
</body>
</html>
`);
});
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 (uniquement si MySQL)
if (config.dbtype === 'mysql') {
try {
console.log(`[LOGIN] Tentative d'authentification DB MySQL 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 req.session.save((err) => {
if (err) {
console.error(`[LOGIN] Erreur sauvegarde session:`, err);
return res.redirect('login?e=1');
}
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 MySQL:`, err);
// Continue vers le fallback config
}
} else {
console.log(`[LOGIN] Mode SQLite détecté, authentification DB IPB désactivée`);
}
// 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;
console.log(`[LOGIN] Session ID: ${req.sessionID}`);
console.log(`[LOGIN] Session avant save:`, { authenticated: req.session.authenticated, user_name: req.session.user_name });
return req.session.save((err) => {
if (err) {
console.error(`[LOGIN] ❌ Erreur sauvegarde session:`, err);
return res.redirect('login?e=1');
}
console.log(`[LOGIN] ✅ Session sauvegardée avec succès`);
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="url(#grad)"/>
<text x="16" y="22" font-family="Arial, sans-serif" font-size="14" font-weight="bold"
text-anchor="middle" fill="white">${initials}</text>
</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) {
console.log(`[AUTH] Vérification session pour ${req.method} ${req.path}`);
console.log(`[AUTH] Session ID: ${req.sessionID}`);
console.log(`[AUTH] Session:`, { authenticated: req.session?.authenticated, user_name: req.session?.user_name });
if (req.session && req.session.authenticated) {
console.log(`[AUTH] ✅ Accès autorisé`);
next();
} else {
console.log(`[AUTH] ❌ Accès refusé, redirection vers login`);
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 damorçage pour le client
const BOOTSTRAP_JSON = JSON.stringify({
csrf: req.session.csrf,
page,
totalPages,
limit
}).replace(/</g, '\\u003c'); // anti </script>
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`);
});