959 lines
32 KiB
JavaScript
959 lines
32 KiB
JavaScript
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)
|
||
? ' | <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
|
||
app.use('/js', express.static(
|
||
path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'),
|
||
{ setHeaders: (res, filepath) => {
|
||
if (filepath.endsWith('.js')) {
|
||
res.setHeader('Content-Type', 'application/javascript');
|
||
}
|
||
}}
|
||
));
|
||
app.use('/jquery', express.static(
|
||
path.join(__dirname, '../node_modules/jquery/dist'),
|
||
{ setHeaders: (res, 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(
|
||
path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'),
|
||
{ setHeaders: (res, filepath) => {
|
||
if (filepath.endsWith('.js')) {
|
||
res.setHeader('Content-Type', 'application/javascript');
|
||
}
|
||
}}
|
||
));
|
||
app.use('/autopost/jquery', express.static(
|
||
path.join(__dirname, '../node_modules/jquery/dist'),
|
||
{ setHeaders: (res, 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"></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
|
||
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 = `<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) {
|
||
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(/</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`);
|
||
});
|