1
0
postauto/autopost/server.js
2025-08-15 04:47:31 +02:00

680 lines
22 KiB
JavaScript
Raw 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 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 => ({
'&': '&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>'
: '';
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">${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}
</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')));
// Servez aussi les vendors sous /autopost
app.use('/js', express.static(
path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'),
{ setHeaders: (res) => res.type('application/javascript') }
));
app.use('/jquery', express.static(
path.join(__dirname, '../node_modules/jquery/dist'),
{ setHeaders: (res) => res.type('application/javascript') }
));
// Miroir des vendors sous /autopost (si reverse-proxy ne forwarde que /autopost/*)
app.use('/autopost/js', express.static(
path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'),
{ setHeaders: (res) => res.type('application/javascript') }
));
app.use('/autopost/jquery', express.static(
path.join(__dirname, '../node_modules/jquery/dist'),
{ setHeaders: (res) => res.type('application/javascript') }
));
// --------------------------- Auth non protégée -----------------------------
autopostRouter.get('/login', (req, res) => {
res.send(`
<!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', (req, res) => {
const { username, password } = req.body;
if (username === config.auth.username && password === config.auth.password) {
req.session.authenticated = true;
res.redirect('/autopost');
} else {
res.send('Identifiants invalides. <a href="/autopost/login">Réessayer</a>');
}
});
autopostRouter.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/autopost/login'));
});
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 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." });
}
});
// --------------------------- 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`);
});