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 += '';
+ h += '- ';
+ if (prevDisabled) {
+ h += 'Précédent';
+ } else {
+ h += 'Précédent';
+ }
+ h += '
';
+ h += '- Page ' + page + ' sur ' + totalPages + '
';
+ h += '- ';
+ if (nextDisabled) {
+ h += 'Suivant';
+ } else {
+ h += 'Suivant';
+ }
+ 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 `
+
+ - ${prev}
+ - Page ${page} sur ${totalPages}
+ - ${next}
+
`;
+}
+
const app = express();
const port = config.port;
const background_color = (config?.background_color ?? '').trim() || 'slate-900';
+app.disable('x-powered-by'); // header express off
+app.use(express.json()); // utile si un jour POST JSON
// Middleware pour parser les formulaires POST
app.use(express.urlencoded({ extended: true }));
@@ -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}
-
-
-
-
-
-
-
-
-
-
Vue d’ensemble des traitements en cours et de l’état des envois.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Name |
- Status |
- ID |
- Actions |
-
-
- `;
+ 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 += `
-
- | ${row.nom} |
- ${statusText} |
- ${row.id} |
-
- Editer |
- Supprimer
- ${logLink}
- ${mediainfoLink}
- ${dlLink}
- |
-
- `;
- });
+ // 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 += `
-
-
-
-
-
-
-
-
-
-
-
-
-
- ✏️ Éditer Release
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 📄 Contenu du log
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Supprimer cet enregistrement ?
-
- Vous êtes sur le point de supprimer . Cette action est irréversible.
-
-
-
-
-
-
-
-
-
-
-
- Supprimé.
-
-
-
-
-
-
-
- `;
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}}
+
+
+
+
+
+
+
+
+
+
+
Vue d’ensemble des traitements en cours et de l’état des envois.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Status |
+ ID |
+ Actions |
+
+
+
+ {{TABLE_ROWS}}
+
+
+
+
+
+
+
+
+
+
+
✏️ Éditer Release
+
+
+
+
+
+
+
+
+
📄 Contenu du log
+
+
+
+
+
+
+
+
+
+
+
+
Supprimer cet enregistrement ?
+
Vous êtes sur le point de supprimer . Cette action est irréversible.
+
+
+
+
+
+
+
+
+
+ Supprimé.
+
+
+
+
+
+
+
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"