diff --git a/autopost/public/autopost.js b/autopost/public/autopost.js new file mode 100644 index 0000000..7f55eef --- /dev/null +++ b/autopost/public/autopost.js @@ -0,0 +1,422 @@ +// === Autopost client script === + +// --- CSRF global --- +(function () { + const meta = document.querySelector('meta[name="csrf-token"]'); + const CSRF_TOKEN = meta ? meta.content : (window.__BOOTSTRAP__ && window.__BOOTSTRAP__.csrf) || ''; + if (window.jQuery) { + $.ajaxSetup({ headers: { 'x-csrf-token': CSRF_TOKEN } }); + } +})(); + +// --- XSS escape côté client --- +function esc(s) { + // ATTENTION: backtick échappé dans la classe de caractères: \` + return String(s).replace(/[&<>"'\`=]/g, c => ({ + '&': '&', '<': '<', '>': '>', + '"': '"', "'": ''', '\`': '`', '=': '=' + }[c])); +} + +// --- Bootstrap values --- +const INITIAL_PAGE = (window.__BOOTSTRAP__ && window.__BOOTSTRAP__.page) || 1; +const INITIAL_TOTAL_PAGES= (window.__BOOTSTRAP__ && window.__BOOTSTRAP__.totalPages) || 1; +const PAGE_LIMIT = (window.__BOOTSTRAP__ && window.__BOOTSTRAP__.limit) || 100; + +let currentPage = INITIAL_PAGE; +let currentTotalPages = INITIAL_TOTAL_PAGES; +let activeFilter = null; // number | null +let activeQuery = ''; // string + +// --- Rendering: table --- +function updateTable(rows) { + var tbody = $('table tbody'); + tbody.empty(); + + rows.forEach(function(row) { + var statusText = ''; + var 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'; + } + + var logLink = (parseInt(row.status) === 1 || parseInt(row.status) === 2 || parseInt(row.status) === 4) + ? ' | Log' + : ''; + + var mediainfoLink = (parseInt(row.status) === 0 || parseInt(row.status) === 1 || parseInt(row.status) === 2) + ? ' | Mediainfo' + : ''; + + var dlLink = (parseInt(row.status) === 1) + ? ' | DL' + : ''; + + var tr = + '' + + '' + esc(row.nom) + '' + + '' + statusText + '' + + '' + row.id + '' + + '' + + 'Editer | ' + + 'Supprimer' + + logLink + mediainfoLink + dlLink + + '' + + ''; + + tbody.append(tr); + }); +} + +// --- Rendering: pagination --- +function renderPaginationHTML(page, totalPages) { + var prevDisabled = page <= 1; + var nextDisabled = page >= totalPages; + + var h = ''; + h += ''; + + return h; +} +function updatePagination(page, totalPages) { + $('#pagination').html(renderPaginationHTML(page, totalPages)); +} + +// --- Data loading --- +function loadPage(p) { + const targetPage = Math.max(1, parseInt(p, 10) || 1); + + const isFilter = activeFilter !== null; + const url = isFilter ? '/autopost/filter' : '/autopost/search'; + const data = isFilter + ? { status: activeFilter, page: targetPage, limit: PAGE_LIMIT } + : { q: activeQuery || '', page: targetPage, limit: PAGE_LIMIT }; + + $.ajax({ + url: url, + type: 'GET', + data: data, + dataType: 'json', + success: function(resp) { + updateTable(resp.rows); + currentPage = resp.page; + currentTotalPages = resp.totalPages; + updatePagination(currentPage, currentTotalPages); + }, + error: function(jqXHR, textStatus, errorThrown) { + if (textStatus !== 'abort') { + console.error('Erreur AJAX :', textStatus, errorThrown); + alert('Erreur lors du chargement de la page.'); + } + } + }); +} + +// --- Long polling: refresh table when new mediainfo/log apparaît --- +(function () { + var lastVersion = null; + + function poll() { + $.ajax({ + url: '/autopost/updates', + type: 'GET', + data: { since: lastVersion || 0 }, + timeout: 30000, + success: function (resp) { + if (resp && typeof resp.version === 'number') { + if (lastVersion === null) { + lastVersion = resp.version; // 1ère synchro: on se cale + } else if (resp.version > lastVersion) { + lastVersion = resp.version; + if (typeof loadPage === 'function') loadPage(currentPage || 1); + if (typeof updateStatsUI === 'function') { + $.getJSON('/autopost/stats', function (s) { if (s) updateStatsUI(s); }); + } + } + } + setTimeout(poll, 100); + }, + error: function () { + setTimeout(poll, 2000); + } + }); + } + + $(document).ready(poll); +})(); + +function updateStatsUI(s) { + $('.filter-card[data-status="0"] .tabular-nums').text(s.attente || 0); + $('.filter-card[data-status="1"] .tabular-nums').text(s.termine || 0); + $('.filter-card[data-status="2"] .tabular-nums').text(s.erreur || 0); + $('.filter-card[data-status="3"] .tabular-nums').text(s.deja || 0); +} + +function refreshAfterChange() { + loadPage(currentPage || 1); + $.getJSON('/autopost/stats', function(s) { updateStatsUI(s); }); +} + +// --- DOM bindings --- +$(document).ready(function() { + // Recherche + let searchTimer = null; + $('#searchInput').on('keyup', function() { + clearTimeout(searchTimer); + let q = $(this).val(); + + searchTimer = setTimeout(function() { + activeQuery = q || ''; + activeFilter = null; // on sort du mode filtre + $('.filter-card').removeClass('ring-4 ring-white/40'); + loadPage(1); // charge page 1 avec pagination AJAX + toggleShowAllButton(); + }, 300); + }); + + // Pagination: intercepter et paginer en AJAX + $(document).on('click', '#pagination a[data-page]', function(e) { + e.preventDefault(); + const p = parseInt($(this).data('page'), 10); + if (!isNaN(p)) loadPage(p); + }); + + // Affichage/masquage du bouton "Tout afficher" + function toggleShowAllButton() { + if ($('.filter-card.ring-4').length === 0) { + $('#showAll').addClass('hidden'); + } else { + $('#showAll').removeClass('hidden'); + } + } + + // Filtrage par clic sur une card + $(document).on('click', '.filter-card', function() { + const status = parseInt($(this).data('status'), 10); + + $('.filter-card').removeClass('ring-4 ring-white/40'); + $(this).addClass('ring-4 ring-white/40'); + + activeFilter = status; + activeQuery = ''; // on sort du mode recherche + loadPage(1); // page 1 du filtre + toggleShowAllButton(); + }); + + // Bouton tout afficher + $(document).on('click', '#showAll', function() { + $('.filter-card').removeClass('ring-4 ring-white/40'); + activeFilter = null; + activeQuery = $('#searchInput').val() || ''; + loadPage(1); + toggleShowAllButton(); + }); + + // Edition (ouvrir modale) + $(document).on('click', '.edit-link', function(e) { + e.preventDefault(); + var releaseId = $(this).data('id'); + var currentStatus = $(this).data('status'); + $('#releaseId').val(releaseId); + $('#modalReleaseId').text(releaseId); + $('#statusSelect').val(currentStatus); + $('#editModal').removeClass('hidden').fadeIn(); + }); + + // ---------- Confirmation de suppression (modale) ---------- + var pendingDeleteId = null; + + function openConfirmModal(id, name) { + pendingDeleteId = id; + $('#confirmItemName').text(name || ('ID ' + id)); + $('#confirmDeleteModal').removeClass('hidden').fadeIn(120); + } + + function closeConfirmModal() { + $('#confirmDeleteModal').fadeOut(120, function() { + $(this).addClass('hidden'); + }); + pendingDeleteId = null; + } + + function showToast(msg) { + $('#toastMsg').text(msg || 'Opération effectuée'); + $('#toast').removeClass('hidden').fadeIn(120); + setTimeout(function() { + $('#toast').fadeOut(150, function() { $(this).addClass('hidden'); }); + }, 1800); + } + + // Ouvrir la modale au clic sur "Supprimer" + $(document).on('click', '.delete-link', function(e) { + e.preventDefault(); + var releaseId = $(this).data('id'); + var name = $(this).closest('tr').find('td:first').text().trim(); + openConfirmModal(releaseId, name); + }); + + // Boutons de la modale + $('#cancelDeleteBtn, #confirmDeleteClose').on('click', function() { + closeConfirmModal(); + }); + + // Clic sur l’overlay pour fermer + $('#confirmDeleteModal').on('click', function(e) { + if (e.target === this) { closeConfirmModal(); } + }); + + // Accessibilité clavier: Esc = fermer, Enter = confirmer + $(document).on('keydown', function(e) { + var modalVisible = !$('#confirmDeleteModal').hasClass('hidden'); + if (!modalVisible) return; + if (e.key === 'Escape') { closeConfirmModal(); } + if (e.key === 'Enter') { $('#confirmDeleteBtn').click(); } + }); + + // Confirmer et supprimer via AJAX + $('#confirmDeleteBtn').on('click', function() { + if (!pendingDeleteId) return; + var releaseId = pendingDeleteId; + + $.ajax({ + url: '/autopost/delete/' + releaseId, + type: 'POST', + success: function() { + $('#row-' + releaseId) + .css('outline', '2px solid rgba(239,68,68,0.6)') + .fadeOut('300', function(){ $(this).remove(); }); + showToast('Enregistrement supprimé'); + }, + error: function() { + alert('Erreur lors de la suppression.'); + }, + complete: function() { + closeConfirmModal(); + } + }); + }); + + // Affichage log + $(document).on('click', '.log-link', function(e) { + e.preventDefault(); + var filename = $(this).data('filename'); + $.ajax({ + url: '/autopost/log', + type: 'GET', + data: { name: filename }, + dataType: 'json', + success: function(data) { + $('#logContent').html(data.content); + $('#logModal').removeClass('hidden').fadeIn(); + }, + error: function() { + alert('Erreur lors du chargement du fichier log.'); + } + }); + }); + + // Affichage mediainfo + $(document).on('click', '.mediainfo-link', function(e) { + e.preventDefault(); + var filename = $(this).data('filename'); + $.ajax({ + url: '/autopost/mediainfo', + type: 'GET', + data: { name: filename }, + dataType: 'json', + success: function(data) { + $('#mediainfoContent').text(data.content); + $('#mediainfoModal').removeClass('hidden').fadeIn(); + }, + error: function() { + alert('Erreur lors du chargement du fichier mediainfo.'); + } + }); + }); + + // Fermeture modales (croix + clic overlay) + $('.close').click(function() { + $(this).closest('.fixed').fadeOut(function() { + $(this).addClass('hidden'); + }); + }); + + $('.fixed').click(function(e) { + if (e.target === this) { + $(this).fadeOut(function() { + $(this).addClass('hidden'); + }); + } + }); + + // Edition: submit + $('#editForm').submit(function(e) { + e.preventDefault(); + var releaseId = $('#releaseId').val(); + var newStatus = $('#statusSelect').val(); + + $.ajax({ + url: '/autopost/edit/' + releaseId, + type: 'POST', + data: { status: newStatus }, + success: function() { + var statusText = ''; + var statusClass = ''; + switch (parseInt(newStatus)) { + 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'; + } + + var row = $('#row-' + releaseId); + row.find('.status-text') + .removeClass('bg-cyan-500 bg-green-300 bg-red-300 bg-pink-300 bg-yellow-300') + .addClass(statusClass) + .text(statusText); + + $('#editModal').fadeOut(function() { + $(this).addClass('hidden'); + }); + }, + error: function() { + alert('Erreur lors de la mise à jour.'); + } + }); + }); + + // Bouton copier Mediainfo + $('#copyMediainfoBtn').on('click', function() { + const content = document.getElementById('mediainfoContent').textContent; + navigator.clipboard.writeText(content).then(() => { + this.textContent = '✅ Copié !'; + setTimeout(() => this.textContent = '📋 Copier JSON', 2000); + }).catch(function(err) { + alert('Erreur lors de la copie : ' + err); + }); + }); +}); diff --git a/autopost/server.js b/autopost/server.js index 7ab5023..3bb5f4d 100644 --- a/autopost/server.js +++ b/autopost/server.js @@ -7,13 +7,14 @@ 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 argon2 = require('argon2'); 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; @@ -22,10 +23,108 @@ function resolveTrustProxy(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' + : ''; + + return ` + + ${esc(row.nom)} + ${statusText} + ${row.id} + + Editer | + Supprimer + ${logLink}${mediainfoLink}${dlLink} + +`; +} + +function renderPaginationHTML(page, totalPages) { + const prevDisabled = page <= 1; + const nextDisabled = page >= totalPages; + + const prev = prevDisabled + ? `Précédent` + : `Précédent`; + + const next = nextDisabled + ? `Suivant` + : `Suivant`; + + return ` +`; +} + const app = express(); const port = config.port; const background_color = (config?.background_color ?? '').trim() || 'slate-900'; +app.disable('x-powered-by'); // header express off +app.use(express.json()); // utile si un jour POST JSON // Middleware pour parser les formulaires POST app.use(express.urlencoded({ extended: true })); @@ -51,47 +150,61 @@ app.use(session({ 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') } // optionnel + { setHeaders: (res) => res.type('application/javascript') } )); app.use('/jquery', express.static( path.join(__dirname, '../node_modules/jquery/dist'), - { setHeaders: (res) => res.type('application/javascript') } // optionnel + { setHeaders: (res) => res.type('application/javascript') } +)); + +// Miroir des vendors sous /autopost (si reverse-proxy ne forwarde que /autopost/*) +app.use('/autopost/js', express.static( + path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'), + { setHeaders: (res) => res.type('application/javascript') } +)); +app.use('/autopost/jquery', express.static( + path.join(__dirname, '../node_modules/jquery/dist'), + { setHeaders: (res) => res.type('application/javascript') } )); // --------------------------- Auth non protégée ----------------------------- autopostRouter.get('/login', (req, res) => { res.send(` - - - - - Login ${config.name} - - - - -
-

Authentification

-
- - - -
-
- - + + + + + Login ${esc(config.name)} + + + + +
+

Authentification

+
+ + + +
+
+ + `); }); @@ -106,76 +219,93 @@ autopostRouter.post('/login', (req, res) => { }); autopostRouter.get('/logout', (req, res) => { - req.session.destroy(); - res.redirect('/autopost/login'); + 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'); - } + 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(); + 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; + const since = parseInt(req.query.since, 10) || 0; - if (lpVersion > since) { - return res.json({ version: lpVersion }); - } + 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) - }; + const waiter = { + res, + timer: setTimeout(() => { + lpWaiters.delete(waiter); + res.json({ version: lpVersion }); // heartbeat/timeout + }, LP_TIMEOUT_MS) + }; - lpWaiters.add(waiter); + lpWaiters.add(waiter); - req.on('close', () => { - clearTimeout(waiter.timer); - lpWaiters.delete(waiter); - }); + req.on('close', () => { + clearTimeout(waiter.timer); + lpWaiters.delete(waiter); + }); }); -// Watcher fichiers (JSON / BDINFO) + fallback scan -const infoDir = path.resolve(config.logdirectory); -const watchPatterns = [ - path.join(infoDir, '*.json'), - path.join(infoDir, '*.JSON'), - path.join(infoDir, '*.bdinfo.txt'), - path.join(infoDir, '*.BDINFO.TXT'), - path.join(infoDir, '*.log') -]; +// 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 + 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)); @@ -188,835 +318,99 @@ 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(infoDir); - const files = names.filter(n => /(?:\.json|\.log|\.bdinfo\.txt)$/i.test(n)); - let latest = 0; - await Promise.all(files.map(async (n) => { - try { - const st = await fsp.stat(path.join(infoDir, n)); - if (st.mtimeMs > latest) latest = st.mtimeMs; - } catch (_) {} - })); - return files.length + ':' + Math.floor(latest); + 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 '0:0'; + 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); - } - } catch (e) { - console.error('[LP] periodic scan error:', e.message || e); - } finally { - setTimeout(periodicScan, 2000); + 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 = parseInt(req.query.page) || 1; + 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}\` - `); + 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.ceil(totalRecords / limit); + 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] ); - let html = ` - - - - - Suivi Autopost ${config.name} - - - - - -
- -
-
-

- Suivi Autopost ${config.name} -

- - - - - Déconnexion - -
-

Vue d’ensemble des traitements en cours et de l’état des envois.

-
- - -
- -
-
-
- - - -
-
En attente
-
-
${stats[0].attente}
-
- - -
-
-
- - - -
-
Terminé
-
-
${stats[0].termine}
-
- - -
-
-
- - - -
-
Erreur
-
-
${stats[0].erreur}
-
- - -
-
-
- - - -
-
Déjà dispo
-
-
${stats[0].deja}
-
-
- - -
- -
- - -
- -
- - - - - - -
-
- - - -
- - - - - - - - - - `; + const tableRowsHTML = rows.map(renderRow).join('\n'); + const paginationHTML = renderPaginationHTML(page, totalPages); - rows.forEach(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'; - } - let logLink = (parseInt(row.status) === 1 || parseInt(row.status) === 2 || parseInt(row.status) === 4) - ? ' | Log' - : ''; - let mediainfoLink = (parseInt(row.status) === 0 || parseInt(row.status) === 1 || parseInt(row.status) === 2) - ? ' | MediaInfo/BDInfo' - : ''; - let dlLink = row.status === 1 - ? ' | DL' - : ''; - - html += ` - - - - - - - `; - }); + // 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 + }); - html += ` - -
NameStatusIDActions
${row.nom}${statusText}${row.id} - Editer | - Supprimer - ${logLink} - ${mediainfoLink} - ${dlLink} -
-
-
- - - - - - - - - - - - - - - - - - - - - - `; res.send(html); } catch (err) { console.error(err.message); @@ -1026,9 +420,9 @@ autopostRouter.get('/', async (req, res) => { // --------------------------- Recherche ----------------------------- autopostRouter.get('/search', async (req, res) => { - const q = req.query.q || ""; - const page = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 100; + 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}%`; @@ -1056,13 +450,11 @@ autopostRouter.get('/search', async (req, res) => { } }); - autopostRouter.get('/log', (req, res) => { - const filename = req.query.name; - if (!filename) { - return res.status(400).json({ error: "Nom du fichier non spécifié." }); + const base = safeBaseName(req.query.name); + if (!base) { + return res.status(400).json({ error: "Nom de fichier invalide." }); } - let base = filename.includes('.') ? filename.split('.').slice(0, -1).join('.') : filename; const logFilePath = path.join(config.logdirectory, base + '.log'); fs.readFile(logFilePath, 'utf8', (err, data) => { if (err) { @@ -1071,33 +463,26 @@ autopostRouter.get('/log', (req, res) => { } // 1) enlever les codes curseur (début de ligne/clear) mais garder les couleurs (…m) - // ex: \x1b[0G, \x1b[0K, etc. — ce sont EUX qui “collent” les blocs let s = data.replace(/\x1b\[[0-9;]*[GK]/g, ''); - // 2) supprimer TOUT segment de progression, même s’ils sont enchaînés sur UNE SEULE ligne - // forme couverte : "12.34% [======] 487.86 MiB/s, ETA 00:01" - // + variantes “ETA -”, et même le cas minimal "0.00% [ ]" + // 2) supprimer TOUT segment de progression const ANSI_M = '(?:\\x1b\\[[0-9;]*m)*'; const NUM = '\\d{1,3}(?:\\.\\d+)?'; const PROG = new RegExp( - // pourcentage ANSI_M + '\\s*' + NUM + '\\s*%' + - // barre '\\s*' + ANSI_M + '\\[[^\\]]*\\]' + - // (option) vitesse + ETA '(?:\\s*' + ANSI_M + NUM + '\\s*MiB\\/s,\\s*ETA\\s*[^\\x1bG\\n]*)?', 'g' ); s = s.replace(PROG, ''); - // 3) nettoyer les résidus "-G" / "G" laissés juste avant G[INFO] (quand une progression était collée) + // 3) nettoyer les résidus "-G" / "G" avant G[INFO] s = s.replace(/-?\s*G(?=\[INFO\])/g, ''); - // 4) petite normalisation (sans casser les couleurs) + // 4) normalisation s = s.replace(/[ \t]+/g, ' ') .replace(/\r/g, '') .replace(/\n{3,}/g, '\n\n') - // Retour à la ligne avant chaque mot-clé (sauf début) .replace(/(?!^)(?=\[INFO\])/gm, '\n') .replace(/(?!^)Calculating\s*:/gm, '\nCalculating :') .replace(/(?!^)Writing\s*:/gm, '\nWriting :') @@ -1105,20 +490,18 @@ autopostRouter.get('/log', (req, res) => { .replace(/(?!^)Finished\s*:/gm, '\nFinished :') .trim(); - const htmlContent = convert.toHtml(s); // conserve les couleurs ANSI + const htmlContent = convert.toHtml(s); res.json({ content: htmlContent }); }); }); - // --------------------------- Mediainfo ----------------------------- autopostRouter.get('/mediainfo', (req, res) => { - const filename = req.query.name; - if (!filename) { - return res.status(400).json({ error: "Nom du fichier non spécifié." }); + const base = safeBaseName(req.query.name); + if (!base) { + return res.status(400).json({ error: "Nom de fichier invalide." }); } - const base = filename.includes('.') ? filename.split('.').slice(0, -1).join('.') : filename; const jsonPath = path.join(config.infodirectory, base + '.json'); const bdinfoPath = path.join(config.infodirectory, base + '.bdinfo.txt'); @@ -1159,23 +542,18 @@ autopostRouter.get('/mediainfo', (req, res) => { // --------------------------- Download ----------------------------- autopostRouter.get('/dl', (req, res) => { - const filename = req.query.name; - if (!filename) { - return res.status(400).send("Nom du fichier non spécifié."); + const base = safeBaseName(req.query.name); + if (!base) { + return res.status(400).send("Nom de fichier invalide."); } - // Extraire le nom de base sans extension - let base = filename.includes('.') ? filename.split('.').slice(0, -1).join('.') : filename; const subfolder = base.charAt(0).toUpperCase(); - // L'archive est en .7z maintenant const archiveFilePath = path.join(config.finishdirectory, subfolder, base + '.7z'); console.log("Tentative de téléchargement (archive 7z) :", archiveFilePath); - // Utilisation du répertoire temporaire const tmpDir = path.join(__dirname, 'tmp'); - // Le fichier extrait attendu (sans dossier interne grâce à "7z e") + fs.mkdirSync(tmpDir, { recursive: true }); // s'assurer que le répertoire existe const extractedFilePath = path.join(tmpDir, base + '.nzb'); - // Utiliser "7z e" pour extraire sans recréer la structure de dossiers const command = `7z e "${archiveFilePath}" -o"${tmpDir}" -y`; exec(command, (error, stdout, stderr) => { @@ -1183,13 +561,11 @@ autopostRouter.get('/dl', (req, res) => { console.error(`Erreur lors de la décompression: ${error.message}`); return res.status(500).send("Erreur lors de la décompression."); } - // On vérifie que le fichier extrait existe 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."); } - // Suppression du fichier temporaire après téléchargement (optionnel) fs.unlink(extractedFilePath, (err) => { if (err) { console.error("Erreur lors de la suppression du fichier temporaire :", err); @@ -1201,48 +577,48 @@ autopostRouter.get('/dl', (req, res) => { // --------------------------- É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.headers.accept.indexOf('json') > -1) { - res.json({ success: true }); - } else { - res.redirect("/autopost/"); - } - } catch (err) { - console.error(err.message); - res.status(500).send("Erreur lors de la mise à jour."); + 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.headers.accept.indexOf('json') > -1) { - res.json({ success: true }); - } else { - res.redirect("/autopost/"); - } - } catch (err) { - console.error(err.message); - res.status(500).send("Erreur lors de la suppression."); + 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)) - ? 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 = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 100; + 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 { @@ -1271,32 +647,34 @@ autopostRouter.get('/filter', async (req, res) => { // --------------------------- 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' }); - } + 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/"); + 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`); + console.log(`Serveur démarré sur http://localhost:${port}/autopost`); }); diff --git a/autopost/views/autopost.html b/autopost/views/autopost.html new file mode 100644 index 0000000..edb7650 --- /dev/null +++ b/autopost/views/autopost.html @@ -0,0 +1,199 @@ + + + + + {{TITLE}} + + + + + + +
+ +
+
+

+ Suivi Autopost {{APP_NAME}} +

+ + + + + Déconnexion + +
+

Vue d’ensemble des traitements en cours et de l’état des envois.

+
+ + +
+ +
+
+
+ + + +
+
En attente
+
+
{{STAT_ATTENTE}}
+
+ + +
+
+
+ + + +
+
Terminé
+
+
{{STAT_TERMINE}}
+
+ + +
+
+
+ + + +
+
Erreur
+
+
{{STAT_ERREUR}}
+
+ + +
+
+
+ + + +
+
Déjà dispo
+
+
{{STAT_DEJA}}
+
+
+ + +
+ +
+ + +
+ +
+ + + + + + +
+
+ + + +
+ + + + + + + + + + + {{TABLE_ROWS}} + +
NameStatusIDActions
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/install.sh b/install.sh index 9c4819c..40d1e49 100644 --- a/install.sh +++ b/install.sh @@ -51,6 +51,8 @@ install_bin() { # install_bin log "Initialisation des dossiers" ensure_dir "$BIN_DIR" ensure_dir "$AUTOPOST_DIR" +ensure_dir "$AUTOPOST_DIR"/public +ensure_dir "$AUTOPOST_DIR"/views ensure_dir "$BASH_COMPLETION_DIR" # Ensure PATH contains $HOME/bin for this session & future shells @@ -328,6 +330,8 @@ popd >/dev/null log "Vérification fichiers Node" [ -f "$AUTOPOST_DIR/server.js" ] || wget -q -O "$AUTOPOST_DIR/server.js" "https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/server.js" [ -f "$AUTOPOST_DIR/db.js" ] || wget -q -O "$AUTOPOST_DIR/db.js" "https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/db.js" +[ -f "$AUTOPOST_DIR/public/autopost.js" ] || wget -q -O "$AUTOPOST_DIR/public/autopost.js" "https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/public/autopost.js" +[ -f "$AUTOPOST_DIR/views/autopost.html" ] || wget -q -O "$AUTOPOST_DIR/views/autopost.html" "https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/views/autopost.html" if [ ! -f "$AUTOPOST_DIR/config.js" ]; then wget -q -O "$AUTOPOST_DIR/config.js" "https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/config.js" ok "Installation terminée. Configurez $AUTOPOST_DIR/config.js." diff --git a/update.sh b/update.sh index 04844af..c3af7aa 100644 --- a/update.sh +++ b/update.sh @@ -88,7 +88,7 @@ BASH_COMPLETION_DIR="$HOME/.bash_completion.d" CONF_SH="$AUTOPOST_DIR/conf.sh" CFG_JS="$AUTOPOST_DIR/config.js" -mkdir -p "$BIN_DIR" "$AUTOPOST_DIR" "$BASH_COMPLETION_DIR" +mkdir -p "$BIN_DIR" "$AUTOPOST_DIR" "$BASH_COMPLETION_DIR" "$AUTOPOST_DIR/views" "$AUTOPOST_DIR/public" TMP_DIR="$(mktemp -d)" cleanup(){ rm -rf "$TMP_DIR"; } @@ -104,6 +104,8 @@ FILES["$AUTOPOST_DIR/posteur.sh"]="https://tig.unfr.pw/UNFR/postauto/raw/branch/ FILES["$AUTOPOST_DIR/common.sh"]="https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/common.sh" FILES["$BIN_DIR/postauto"]="https://tig.unfr.pw/UNFR/postauto/raw/branch/main/bin/postauto" FILES["$AUTOPOST_DIR/server.js"]="https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/server.js" +FILES["$AUTOPOST_DIR/public/autopost.js"]="https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/public/autopost.js" +FILES["$AUTOPOST_DIR/views/autopost.html"]="https://tig.unfr.pw/UNFR/postauto/raw/branch/main/autopost/views/autopost.html" log "Vérification/MAJ des fichiers…" for LOCAL in "${!FILES[@]}"; do @@ -180,7 +182,7 @@ fi if ! ensure_cmd BDInfo; then log "Installation BDInfo…" pushd "$TMP_DIR" >/dev/null - curl -fsSL -o bdinfo.zip "https://github.com/dotnetcorecorner/BDInfo/releases/download/linux-2.0.6/bdinfo_linux_v2.0.6.zip" + wget -q -o /dev/null -O bdinfo.zip "https://github.com/dotnetcorecorner/BDInfo/releases/download/linux-2.0.6/bdinfo_linux_v2.0.6.zip" unzip -q bdinfo.zip BDBIN="$(find . -type f -name BDInfo -perm -u+x | head -n1)" [ -n "$BDBIN" ] || die "BDInfo introuvable" @@ -191,7 +193,7 @@ fi if ! ensure_cmd BDInfoDataSubstractor; then log "Installation BDInfoDataSubstractor…" pushd "$TMP_DIR" >/dev/null - curl -fsSL -o substractor.zip "https://github.com/dotnetcorecorner/BDInfo/releases/download/linux-2.0.6/bdinfodatasubstractor_linux_v2.0.6.zip" + wget -q -o /dev/null -O substractor.zip "https://github.com/dotnetcorecorner/BDInfo/releases/download/linux-2.0.6/bdinfodatasubstractor_linux_v2.0.6.zip" unzip -q substractor.zip SBBIN="$(find . -type f -name BDInfoDataSubstractor -perm -u+x | head -n1)" [ -n "$SBBIN" ] || die "BDInfoDataSubstractor introuvable"