2025-03-12 12:47:51 +01:00
|
|
|
|
const express = require('express');
|
|
|
|
|
|
const session = require('express-session');
|
2025-08-11 13:43:53 +02:00
|
|
|
|
const FileStore = require('session-file-store')(session);
|
2025-03-12 12:47:51 +01:00
|
|
|
|
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');
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const crypto = require('crypto'); // CSRF
|
2025-09-27 15:54:52 +02:00
|
|
|
|
const argon2 = require('argon2'); // Pour l'authentification DB
|
2025-03-12 12:47:51 +01:00
|
|
|
|
const config = require('./config');
|
2025-06-23 14:27:18 +02:00
|
|
|
|
const db = require('./db');
|
2025-08-12 11:32:02 +02:00
|
|
|
|
const chokidar = require('chokidar');
|
2025-08-10 11:49:16 +02:00
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
db.testConnection(); // vérification au démarrage
|
2025-03-12 12:47:51 +01:00
|
|
|
|
|
2025-08-13 07:59:45 +02:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// -------- Helpers sécurité / robustesse --------
|
|
|
|
|
|
function esc(s) {
|
|
|
|
|
|
return String(s).replace(/[&<>"'`=]/g, c => ({
|
|
|
|
|
|
'&': '&', '<': '<', '>': '>',
|
|
|
|
|
|
'"': '"', "'": ''', '`': '`', '=': '='
|
|
|
|
|
|
}[c]));
|
|
|
|
|
|
}
|
|
|
|
|
|
function safeBaseName(input) {
|
|
|
|
|
|
if (typeof input !== 'string') return null;
|
|
|
|
|
|
const onlyName = path.basename(input);
|
|
|
|
|
|
if (onlyName !== input) return null;
|
|
|
|
|
|
if (!/^[\w .\-()[\]]{1,200}$/.test(onlyName)) return null;
|
|
|
|
|
|
return path.parse(onlyName).name; // sans extension
|
|
|
|
|
|
}
|
|
|
|
|
|
function clamp(n, min, max) {
|
|
|
|
|
|
n = parseInt(n, 10);
|
|
|
|
|
|
if (isNaN(n)) n = min;
|
|
|
|
|
|
return Math.max(min, Math.min(max, n));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------- Mini moteur de template (fichier HTML avec placeholders) --------
|
|
|
|
|
|
const DEV = process.env.NODE_ENV !== 'production';
|
|
|
|
|
|
const TEMPLATE_AUTPOST_PATH = path.join(__dirname, 'views', 'autopost.html');
|
|
|
|
|
|
let _tplAutopostCache = null;
|
|
|
|
|
|
|
|
|
|
|
|
function getAutopostTemplate() {
|
|
|
|
|
|
if (DEV) {
|
|
|
|
|
|
return fs.readFileSync(TEMPLATE_AUTPOST_PATH, 'utf8');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!_tplAutopostCache) {
|
|
|
|
|
|
_tplAutopostCache = fs.readFileSync(TEMPLATE_AUTPOST_PATH, 'utf8');
|
|
|
|
|
|
}
|
|
|
|
|
|
return _tplAutopostCache;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remplace {{PLACEHOLDER}} par data[PLACEHOLDER]
|
|
|
|
|
|
function renderTemplate(tpl, data) {
|
|
|
|
|
|
return tpl.replace(/{{\s*([A-Z0-9_]+)\s*}}/g, (_, k) => {
|
|
|
|
|
|
return Object.prototype.hasOwnProperty.call(data, k) ? String(data[k]) : '';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderRow(row) {
|
|
|
|
|
|
let statusText = '';
|
|
|
|
|
|
let statusClass = '';
|
|
|
|
|
|
switch (parseInt(row.status)) {
|
|
|
|
|
|
case 0: statusText = 'EN ATTENTE'; statusClass = 'bg-cyan-500 text-black font-bold'; break;
|
|
|
|
|
|
case 1: statusText = 'ENVOI TERMINÉ'; statusClass = 'bg-green-300 text-black font-bold'; break;
|
|
|
|
|
|
case 2: statusText = 'ERREUR'; statusClass = 'bg-red-300 text-black font-bold'; break;
|
|
|
|
|
|
case 3: statusText = 'DEJA DISPONIBLE'; statusClass = 'bg-pink-300 text-black font-bold'; break;
|
|
|
|
|
|
case 4: statusText = 'EN COURS'; statusClass = 'bg-yellow-300 text-black font-bold'; break;
|
|
|
|
|
|
default: statusText = 'INCONNU';
|
|
|
|
|
|
}
|
|
|
|
|
|
const logLink = (parseInt(row.status) === 1 || parseInt(row.status) === 2 || parseInt(row.status) === 4)
|
|
|
|
|
|
? ' | <a href="#" class="log-link text-blue-400 hover:underline" data-filename="' + esc(row.nom) + '">Log</a>'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
const mediainfoLink = (parseInt(row.status) === 0 || parseInt(row.status) === 1 || parseInt(row.status) === 2)
|
|
|
|
|
|
? ' | <a href="#" class="mediainfo-link text-blue-400 hover:underline" data-filename="' + esc(row.nom) + '">MediaInfo/BDInfo</a>'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
const dlLink = (parseInt(row.status) === 1)
|
|
|
|
|
|
? ' | <a href="/autopost/dl?name=' + encodeURIComponent(row.nom) + '" class="dl-link text-blue-400 hover:underline">DL</a>'
|
|
|
|
|
|
: '';
|
2025-09-27 15:06:16 +02:00
|
|
|
|
const resendLink = (parseInt(row.status) === 1)
|
|
|
|
|
|
? ' | <a href="#" class="resend-link text-green-400 hover:underline" data-id="' + row.id + '" data-filename="' + esc(row.nom) + '">Renvoyer</a>'
|
|
|
|
|
|
: '';
|
2025-08-14 19:27:48 +02:00
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<tr id="row-${row.id}" data-status="${row.status}" class="odd:bg-gray-800 even:bg-gray-700">
|
2025-09-27 15:06:16 +02:00
|
|
|
|
<td class="px-4 py-2 border border-gray-700 text-center">
|
|
|
|
|
|
<input type="checkbox" class="row-checkbox rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500" data-id="${row.id}" data-name="${esc(row.nom)}">
|
|
|
|
|
|
</td>
|
2025-08-14 19:27:48 +02:00
|
|
|
|
<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>
|
2025-09-27 15:06:16 +02:00
|
|
|
|
${logLink}${mediainfoLink}${dlLink}${resendLink}
|
2025-08-14 19:27:48 +02:00
|
|
|
|
</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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-12 12:47:51 +01:00
|
|
|
|
const app = express();
|
|
|
|
|
|
const port = config.port;
|
2025-08-12 11:32:02 +02:00
|
|
|
|
const background_color = (config?.background_color ?? '').trim() || 'slate-900';
|
2025-03-12 12:47:51 +01:00
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
app.disable('x-powered-by'); // header express off
|
|
|
|
|
|
app.use(express.json()); // utile si un jour POST JSON
|
2025-03-12 12:47:51 +01:00
|
|
|
|
// Middleware pour parser les formulaires POST
|
|
|
|
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
|
|
|
2025-08-13 07:59:45 +02:00
|
|
|
|
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);
|
|
|
|
|
|
|
2025-03-12 12:47:51 +01:00
|
|
|
|
app.use(session({
|
2025-08-13 07:59:45 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}));
|
|
|
|
|
|
app.use(express.static('public'));
|
|
|
|
|
|
|
|
|
|
|
|
const autopostRouter = express.Router();
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// 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
|
2025-08-10 11:49:16 +02:00
|
|
|
|
app.use('/js', express.static(
|
|
|
|
|
|
path.join(__dirname, '../node_modules/@tailwindcss/browser/dist'),
|
2025-08-14 19:27:48 +02:00
|
|
|
|
{ setHeaders: (res) => res.type('application/javascript') }
|
2025-08-10 11:49:16 +02:00
|
|
|
|
));
|
|
|
|
|
|
app.use('/jquery', express.static(
|
|
|
|
|
|
path.join(__dirname, '../node_modules/jquery/dist'),
|
2025-08-14 19:27:48 +02:00
|
|
|
|
{ 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') }
|
2025-08-10 11:49:16 +02:00
|
|
|
|
));
|
2025-03-15 15:26:12 +01:00
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// --------------------------- Auth non protégée -----------------------------
|
2025-03-12 12:47:51 +01:00
|
|
|
|
autopostRouter.get('/login', (req, res) => {
|
|
|
|
|
|
res.send(`
|
2025-08-14 19:27:48 +02:00
|
|
|
|
<!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>
|
2025-03-12 12:47:51 +01:00
|
|
|
|
`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-27 15:06:16 +02:00
|
|
|
|
autopostRouter.post('/login', async (req, res) => {
|
|
|
|
|
|
const { username, password } = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[LOGIN] Tentative de connexion pour "${username}"`);
|
|
|
|
|
|
console.log(`[LOGIN] Password reçu: longueur ${password ? password.length : 0} caractères`);
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Essayer d'abord l'authentification via la base de données
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log(`[LOGIN] Tentative d'authentification DB pour "${username}"`);
|
|
|
|
|
|
const [rows] = await db.query(
|
|
|
|
|
|
'SELECT * FROM core_members WHERE name = ? AND member_group_id IN (1,2) LIMIT 1',
|
|
|
|
|
|
[username]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[LOGIN] Nombre de résultats DB: ${rows.length}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (rows.length > 0) {
|
|
|
|
|
|
const member = rows[0];
|
|
|
|
|
|
console.log(`[LOGIN] Utilisateur trouvé en DB:`, {
|
|
|
|
|
|
member_id: member.member_id,
|
|
|
|
|
|
name: member.name,
|
|
|
|
|
|
group: member.member_group_id
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Vérification du hash Argon2id
|
|
|
|
|
|
console.log(`[LOGIN] Vérification du mot de passe avec Argon2id`);
|
|
|
|
|
|
const valid = await argon2.verify(member.members_pass_hash, password);
|
|
|
|
|
|
console.log(`[LOGIN] Résultat vérification Argon2id: ${valid}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (valid) {
|
|
|
|
|
|
console.log(`[LOGIN] ✅ Authentification DB réussie pour "${member.name}"`);
|
2025-06-23 14:27:18 +02:00
|
|
|
|
req.session.authenticated = true;
|
2025-09-27 15:06:16 +02:00
|
|
|
|
req.session.user_id = member.member_id;
|
|
|
|
|
|
req.session.user_name = member.name;
|
|
|
|
|
|
return res.redirect('/autopost');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn(`[LOGIN] ❌ Mot de passe DB incorrect pour "${member.name}"`);
|
|
|
|
|
|
}
|
2025-06-23 14:27:18 +02:00
|
|
|
|
} else {
|
2025-09-27 15:06:16 +02:00
|
|
|
|
console.log(`[LOGIN] Aucun utilisateur trouvé en DB pour "${username}"`);
|
2025-06-23 14:27:18 +02:00
|
|
|
|
}
|
2025-09-27 15:06:16 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(`[LOGIN] Erreur DB:`, err);
|
|
|
|
|
|
// Continue vers le fallback config
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Fallback : authentification via fichier config
|
|
|
|
|
|
console.log(`[LOGIN] Tentative d'authentification config pour "${username}"`);
|
|
|
|
|
|
if (username === config.auth.username && password === config.auth.password) {
|
|
|
|
|
|
console.log(`[LOGIN] ✅ Authentification config réussie pour "${username}"`);
|
|
|
|
|
|
req.session.authenticated = true;
|
|
|
|
|
|
req.session.user_name = username;
|
|
|
|
|
|
return res.redirect('/autopost');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`[LOGIN] ❌ Authentification config échouée pour "${username}"`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Échec des deux méthodes
|
|
|
|
|
|
console.warn(`[LOGIN] 🚫 Échec total d'authentification pour "${username}"`);
|
|
|
|
|
|
res.redirect('login?e=1');
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
2025-08-10 11:49:16 +02:00
|
|
|
|
|
2025-03-12 12:47:51 +01:00
|
|
|
|
autopostRouter.get('/logout', (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
req.session.destroy(() => res.redirect('/autopost/login'));
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function checkAuth(req, res, next) {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
if (req.session && req.session.authenticated) {
|
|
|
|
|
|
next();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
res.redirect(req.baseUrl + '/login');
|
|
|
|
|
|
}
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
autopostRouter.use(checkAuth);
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2025-08-12 11:32:02 +02:00
|
|
|
|
// ---- Long-polling refresh (robuste) ----
|
|
|
|
|
|
let lpVersion = 1;
|
|
|
|
|
|
const lpWaiters = new Set();
|
|
|
|
|
|
const LP_TIMEOUT_MS = 25000;
|
|
|
|
|
|
|
|
|
|
|
|
function lpNotify(source, filePath) {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
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();
|
2025-08-12 11:32:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Endpoint long-polling
|
|
|
|
|
|
autopostRouter.get('/updates', (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const since = parseInt(req.query.since, 10) || 0;
|
2025-08-12 11:32:02 +02:00
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
if (lpVersion > since) {
|
|
|
|
|
|
return res.json({ version: lpVersion });
|
|
|
|
|
|
}
|
2025-08-12 11:32:02 +02:00
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const waiter = {
|
|
|
|
|
|
res,
|
|
|
|
|
|
timer: setTimeout(() => {
|
|
|
|
|
|
lpWaiters.delete(waiter);
|
|
|
|
|
|
res.json({ version: lpVersion }); // heartbeat/timeout
|
|
|
|
|
|
}, LP_TIMEOUT_MS)
|
|
|
|
|
|
};
|
2025-08-12 11:32:02 +02:00
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
lpWaiters.add(waiter);
|
2025-08-12 11:32:02 +02:00
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
req.on('close', () => {
|
|
|
|
|
|
clearTimeout(waiter.timer);
|
|
|
|
|
|
lpWaiters.delete(waiter);
|
|
|
|
|
|
});
|
2025-08-12 11:32:02 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// 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')
|
|
|
|
|
|
]));
|
2025-08-12 11:32:02 +02:00
|
|
|
|
|
|
|
|
|
|
const lpWatcher = chokidar.watch(watchPatterns, {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
ignoreInitial: true,
|
|
|
|
|
|
awaitWriteFinish: { stabilityThreshold: 1200, pollInterval: 250 },
|
|
|
|
|
|
usePolling: true, // important pour NFS/SMB/Docker
|
|
|
|
|
|
interval: 1000,
|
|
|
|
|
|
depth: 0,
|
|
|
|
|
|
ignorePermissionErrors: true,
|
|
|
|
|
|
persistent: true
|
2025-08-12 11:32:02 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
lpWatcher.on('add', (filePath) => lpNotify('add', filePath));
|
|
|
|
|
|
lpWatcher.on('change', (filePath) => lpNotify('change', filePath));
|
2025-08-13 21:58:04 +02:00
|
|
|
|
//lpWatcher.on('ready', () => console.log('[LP] watcher ready on', watchPatterns));
|
2025-08-12 11:32:02 +02:00
|
|
|
|
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() {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
async function listSig(dir, rx) {
|
2025-08-12 11:32:02 +02:00
|
|
|
|
try {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
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 };
|
2025-08-12 11:32:02 +02:00
|
|
|
|
} catch {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
return { count: 0, latest: 0 };
|
2025-08-12 11:32:02 +02:00
|
|
|
|
}
|
2025-08-14 19:27:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2025-08-12 11:32:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function periodicScan() {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
try {
|
|
|
|
|
|
const sig = await computeSignature();
|
|
|
|
|
|
if (lastSig === null) {
|
|
|
|
|
|
lastSig = sig; // baseline
|
|
|
|
|
|
} else if (sig !== lastSig) {
|
|
|
|
|
|
lastSig = sig;
|
|
|
|
|
|
lpNotify('scan', infoDir + '|' + logDir);
|
2025-08-12 11:32:02 +02:00
|
|
|
|
}
|
2025-08-14 19:27:48 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[LP] periodic scan error:', e.message || e);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setTimeout(periodicScan, 2000);
|
|
|
|
|
|
}
|
2025-08-12 11:32:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
periodicScan();
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// --------------------------- PAGE PRINCIPALE -----------------------------
|
|
|
|
|
|
autopostRouter.get('/', async (req, res) => {
|
2025-03-12 12:47:51 +01:00
|
|
|
|
const limit = 100; // enregistrements par page
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const page = clamp(req.query.page, 1, 1_000_000);
|
2025-03-12 12:47:51 +01:00
|
|
|
|
const offset = (page - 1) * limit;
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
try {
|
2025-08-11 13:43:53 +02:00
|
|
|
|
const [stats] = await db.query(`
|
2025-08-14 19:27:48 +02:00
|
|
|
|
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}\`
|
|
|
|
|
|
`);
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// Récupérer le nombre total d'enregistrements
|
2025-08-08 23:07:13 +02:00
|
|
|
|
const [countResult] = await db.query(`SELECT COUNT(*) as total FROM \`${config.DB_TABLE}\``);
|
2025-06-23 14:27:18 +02:00
|
|
|
|
const totalRecords = countResult[0].total;
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalRecords / limit));
|
2025-03-12 12:47:51 +01:00
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
const [rows] = await db.query(
|
2025-08-08 23:07:13 +02:00
|
|
|
|
`SELECT nom, status, id FROM \`${config.DB_TABLE}\` ORDER BY (status = 2) DESC, id DESC LIMIT ? OFFSET ?`,
|
2025-06-23 14:27:18 +02:00
|
|
|
|
[limit, offset]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const tableRowsHTML = rows.map(renderRow).join('\n');
|
|
|
|
|
|
const paginationHTML = renderPaginationHTML(page, totalPages);
|
|
|
|
|
|
|
|
|
|
|
|
// JSON d’amorçage pour le client
|
|
|
|
|
|
const BOOTSTRAP_JSON = JSON.stringify({
|
|
|
|
|
|
csrf: req.session.csrf,
|
|
|
|
|
|
page,
|
|
|
|
|
|
totalPages,
|
|
|
|
|
|
limit
|
|
|
|
|
|
}).replace(/</g, '\\u003c'); // anti </script>
|
|
|
|
|
|
|
|
|
|
|
|
const tpl = getAutopostTemplate();
|
|
|
|
|
|
const html = renderTemplate(tpl, {
|
|
|
|
|
|
TITLE: `Suivi Autopost ${esc(config.name)}`,
|
|
|
|
|
|
CSRF_TOKEN: esc(req.session.csrf),
|
|
|
|
|
|
BACKGROUND_CLASS: background_color, // ex: slate-900
|
|
|
|
|
|
APP_NAME: esc(config.name),
|
|
|
|
|
|
STAT_ATTENTE: stats[0].attente || 0,
|
|
|
|
|
|
STAT_TERMINE: stats[0].termine || 0,
|
|
|
|
|
|
STAT_ERREUR: stats[0].erreur || 0,
|
|
|
|
|
|
STAT_DEJA: stats[0].deja || 0,
|
|
|
|
|
|
PAGINATION_HTML: paginationHTML,
|
|
|
|
|
|
TABLE_ROWS: tableRowsHTML,
|
|
|
|
|
|
BOOTSTRAP_JSON
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
res.send(html);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err.message);
|
|
|
|
|
|
res.status(500).send("Erreur lors de la requête DB.");
|
|
|
|
|
|
}
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// --------------------------- Recherche -----------------------------
|
|
|
|
|
|
autopostRouter.get('/search', async (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
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;
|
2025-08-12 10:15:05 +02:00
|
|
|
|
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." });
|
|
|
|
|
|
}
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
autopostRouter.get('/log', (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const base = safeBaseName(req.query.name);
|
|
|
|
|
|
if (!base) {
|
|
|
|
|
|
return res.status(400).json({ error: "Nom de fichier invalide." });
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}
|
2025-03-12 15:46:32 +01:00
|
|
|
|
const logFilePath = path.join(config.logdirectory, base + '.log');
|
2025-03-12 12:47:51 +01:00
|
|
|
|
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." });
|
|
|
|
|
|
}
|
2025-08-11 15:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
// 1) enlever les codes curseur (début de ligne/clear) mais garder les couleurs (…m)
|
|
|
|
|
|
let s = data.replace(/\x1b\[[0-9;]*[GK]/g, '');
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// 2) supprimer TOUT segment de progression
|
2025-08-11 15:22:45 +02:00
|
|
|
|
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, '');
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// 3) nettoyer les résidus "-G" / "G" avant G[INFO]
|
2025-08-11 15:22:45 +02:00
|
|
|
|
s = s.replace(/-?\s*G(?=\[INFO\])/g, '');
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// 4) normalisation
|
2025-08-11 15:22:45 +02:00
|
|
|
|
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();
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const htmlContent = convert.toHtml(s);
|
2025-03-12 12:47:51 +01:00
|
|
|
|
res.json({ content: htmlContent });
|
|
|
|
|
|
});
|
2025-08-11 15:22:45 +02:00
|
|
|
|
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// --------------------------- Mediainfo -----------------------------
|
2025-03-12 12:47:51 +01:00
|
|
|
|
autopostRouter.get('/mediainfo', (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const base = safeBaseName(req.query.name);
|
|
|
|
|
|
if (!base) {
|
|
|
|
|
|
return res.status(400).json({ error: "Nom de fichier invalide." });
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}
|
2025-08-07 16:13:22 +02:00
|
|
|
|
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é." });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-08-07 16:13:22 +02:00
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// --------------------------- Download -----------------------------
|
2025-03-12 12:47:51 +01:00
|
|
|
|
autopostRouter.get('/dl', (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const base = safeBaseName(req.query.name);
|
|
|
|
|
|
if (!base) {
|
|
|
|
|
|
return res.status(400).send("Nom de fichier invalide.");
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
const subfolder = base.charAt(0).toUpperCase();
|
2025-03-12 15:46:32 +01:00
|
|
|
|
const archiveFilePath = path.join(config.finishdirectory, subfolder, base + '.7z');
|
2025-03-12 12:47:51 +01:00
|
|
|
|
console.log("Tentative de téléchargement (archive 7z) :", archiveFilePath);
|
|
|
|
|
|
|
2025-04-17 19:24:47 +02:00
|
|
|
|
const tmpDir = path.join(__dirname, 'tmp');
|
2025-08-14 19:27:48 +02:00
|
|
|
|
fs.mkdirSync(tmpDir, { recursive: true }); // s'assurer que le répertoire existe
|
2025-03-12 12:47:51 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// --------------------------- Édition -----------------------------
|
|
|
|
|
|
autopostRouter.post('/edit/:id', async (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
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/");
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}
|
2025-08-14 19:27:48 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err.message);
|
|
|
|
|
|
res.status(500).send("Erreur lors de la mise à jour.");
|
|
|
|
|
|
}
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// --------------------------- Suppression -----------------------------
|
|
|
|
|
|
autopostRouter.post('/delete/:id', async (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
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/");
|
2025-03-12 12:47:51 +01:00
|
|
|
|
}
|
2025-08-14 19:27:48 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err.message);
|
|
|
|
|
|
res.status(500).json({ error: "Erreur lors de la suppression." });
|
|
|
|
|
|
}
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-27 15:06:16 +02:00
|
|
|
|
// --------------------------- Opérations en lot -----------------------------
|
|
|
|
|
|
|
|
|
|
|
|
// Édition en lot
|
|
|
|
|
|
autopostRouter.post('/bulk-edit', async (req, res) => {
|
|
|
|
|
|
const { ids, status } = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
|
|
|
|
return res.status(400).json({ error: 'Liste d\'IDs invalide' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const statusInt = parseInt(status, 10);
|
|
|
|
|
|
if (![0, 1, 2, 3, 4].includes(statusInt)) {
|
|
|
|
|
|
return res.status(400).json({ error: 'Statut invalide' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Valider que tous les IDs sont des entiers positifs
|
|
|
|
|
|
const validIds = ids.filter(id => parseInt(id, 10) > 0).map(id => parseInt(id, 10));
|
|
|
|
|
|
if (validIds.length === 0) {
|
|
|
|
|
|
return res.status(400).json({ error: 'Aucun ID valide' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const placeholders = validIds.map(() => '?').join(',');
|
|
|
|
|
|
const query = `UPDATE \`${config.DB_TABLE}\` SET status = ? WHERE id IN (${placeholders})`;
|
|
|
|
|
|
const params = [statusInt, ...validIds];
|
|
|
|
|
|
|
|
|
|
|
|
const [result] = await db.query(query, params);
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
message: `${result.affectedRows} élément(s) mis à jour`,
|
|
|
|
|
|
updated: result.affectedRows
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err.message);
|
|
|
|
|
|
res.status(500).json({ error: 'Erreur DB lors de la mise à jour en lot' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Suppression en lot
|
|
|
|
|
|
autopostRouter.post('/bulk-delete', async (req, res) => {
|
|
|
|
|
|
const { ids } = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
|
|
|
|
return res.status(400).json({ error: 'Liste d\'IDs invalide' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Valider que tous les IDs sont des entiers positifs
|
|
|
|
|
|
const validIds = ids.filter(id => parseInt(id, 10) > 0).map(id => parseInt(id, 10));
|
|
|
|
|
|
if (validIds.length === 0) {
|
|
|
|
|
|
return res.status(400).json({ error: 'Aucun ID valide' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const placeholders = validIds.map(() => '?').join(',');
|
|
|
|
|
|
const query = `DELETE FROM \`${config.DB_TABLE}\` WHERE id IN (${placeholders})`;
|
|
|
|
|
|
|
|
|
|
|
|
const [result] = await db.query(query, validIds);
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
message: `${result.affectedRows} élément(s) supprimé(s)`,
|
|
|
|
|
|
deleted: result.affectedRows
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err.message);
|
|
|
|
|
|
res.status(500).json({ error: 'Erreur DB lors de la suppression en lot' });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// --------------------------- Renvoi -----------------------------
|
|
|
|
|
|
autopostRouter.post('/resend/:id', async (req, res) => {
|
|
|
|
|
|
const id = req.params.id;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Récupérer les informations de l'enregistrement
|
|
|
|
|
|
const [rows] = await db.query(`SELECT nom, status FROM \`${config.DB_TABLE}\` WHERE id = ?`, [id]);
|
|
|
|
|
|
|
|
|
|
|
|
if (rows.length === 0) {
|
|
|
|
|
|
return res.status(404).json({ error: "Enregistrement non trouvé." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const row = rows[0];
|
|
|
|
|
|
const fileName = row.nom;
|
|
|
|
|
|
const status = parseInt(row.status);
|
|
|
|
|
|
|
|
|
|
|
|
// Vérifier que le statut est "ENVOI TERMINÉ" (status = 1)
|
|
|
|
|
|
if (status !== 1) {
|
|
|
|
|
|
return res.status(400).json({ error: "Seuls les enregistrements avec statut 'ENVOI TERMINÉ' peuvent être renvoyés." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const base = safeBaseName(fileName);
|
|
|
|
|
|
if (!base) {
|
|
|
|
|
|
return res.status(400).json({ error: "Nom de fichier invalide." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Chemins des fichiers nécessaires
|
|
|
|
|
|
const subfolder = base.charAt(0).toUpperCase();
|
|
|
|
|
|
const nzbArchivePath = path.join(config.finishdirectory, subfolder, base + '.7z');
|
|
|
|
|
|
const jsonPath = path.join(config.infodirectory, base + '.json');
|
|
|
|
|
|
const bdinfoPath = path.join(config.infodirectory, base + '.bdinfo.txt');
|
|
|
|
|
|
const quicksummaryPath = path.join(config.infodirectory, base + '.quicksummary.txt');
|
|
|
|
|
|
|
|
|
|
|
|
// Vérifier que l'archive NZB existe
|
|
|
|
|
|
if (!fs.existsSync(nzbArchivePath)) {
|
|
|
|
|
|
return res.status(404).json({ error: "Archive NZB non trouvée." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Extraire le NZB temporairement
|
|
|
|
|
|
const tmpDir = path.join(__dirname, 'tmp');
|
|
|
|
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
|
|
|
|
const tmpNzbPath = path.join(tmpDir, base + '.nzb');
|
|
|
|
|
|
|
|
|
|
|
|
const extractCommand = `7z e "${nzbArchivePath}" -o"${tmpDir}" -y`;
|
|
|
|
|
|
|
|
|
|
|
|
exec(extractCommand, (extractError) => {
|
|
|
|
|
|
if (extractError) {
|
|
|
|
|
|
console.error(`Erreur lors de l'extraction: ${extractError.message}`);
|
|
|
|
|
|
return res.status(500).json({ error: "Erreur lors de l'extraction du NZB." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Vérifier si c'est un ISO (BDInfo) ou autre (JSON)
|
|
|
|
|
|
const isIso = fileName.toLowerCase().endsWith('.iso');
|
|
|
|
|
|
let curlCommand;
|
|
|
|
|
|
|
|
|
|
|
|
if (isIso) {
|
|
|
|
|
|
// Pour les ISO : bdinfo_full + bdinfo_mini + nzb
|
|
|
|
|
|
if (!fs.existsSync(bdinfoPath) || !fs.existsSync(quicksummaryPath)) {
|
|
|
|
|
|
fs.unlinkSync(tmpNzbPath);
|
|
|
|
|
|
return res.status(404).json({ error: "Fichiers BDInfo non trouvés." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
curlCommand = `curl -s -k -L -m 60 \\
|
|
|
|
|
|
-F rlsname=${base} \\
|
|
|
|
|
|
-F bdinfo_full=@${bdinfoPath} \\
|
|
|
|
|
|
-F bdinfo_mini=@${quicksummaryPath} \\
|
|
|
|
|
|
-F nzb=@${tmpNzbPath} \\
|
|
|
|
|
|
-F upload=upload "${config.apiUrl}${config.apiKey}"`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Pour les autres : generated_nfo_json + nzb
|
|
|
|
|
|
if (!fs.existsSync(jsonPath)) {
|
|
|
|
|
|
fs.unlinkSync(tmpNzbPath);
|
|
|
|
|
|
return res.status(404).json({ error: "Fichier JSON non trouvé." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
curlCommand = `curl -s -k -L -m 60 \\
|
|
|
|
|
|
-F rlsname=${base} \\
|
|
|
|
|
|
-F generated_nfo_json=@${jsonPath} \\
|
|
|
|
|
|
-F nzb=@${tmpNzbPath} \\
|
|
|
|
|
|
-F upload=upload "${config.apiUrl}${config.apiKey}"`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Exécuter la commande curl
|
|
|
|
|
|
exec(curlCommand, (curlError, stdout, stderr) => {
|
|
|
|
|
|
// Nettoyer le fichier temporaire
|
|
|
|
|
|
fs.unlinkSync(tmpNzbPath);
|
|
|
|
|
|
|
|
|
|
|
|
// Log des sorties curl pour debug
|
|
|
|
|
|
console.log(`=== CURL OUTPUT pour ${fileName} ===`);
|
|
|
|
|
|
console.log(`Command: ${curlCommand}`);
|
|
|
|
|
|
if (stdout) console.log(`STDOUT: ${stdout}`);
|
|
|
|
|
|
if (stderr) console.log(`STDERR: ${stderr}`);
|
|
|
|
|
|
console.log(`================================`);
|
|
|
|
|
|
|
|
|
|
|
|
if (curlError) {
|
|
|
|
|
|
console.error(`Erreur lors du renvoi: ${curlError.message}`);
|
|
|
|
|
|
return res.status(500).json({ error: "Erreur lors du renvoi vers le site principal." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`Renvoi réussi pour ${fileName}`);
|
|
|
|
|
|
res.json({ success: true, message: "Renvoi effectué avec succès." });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err.message);
|
|
|
|
|
|
res.status(500).json({ error: "Erreur lors du renvoi." });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-12 10:15:05 +02:00
|
|
|
|
// --------------------------- Filtrage -----------------------------
|
2025-08-11 18:43:12 +02:00
|
|
|
|
autopostRouter.get('/filter', async (req, res) => {
|
2025-08-12 10:15:05 +02:00
|
|
|
|
const status = Number.isFinite(parseInt(req.query.status, 10))
|
2025-08-14 19:27:48 +02:00
|
|
|
|
? clamp(req.query.status, 0, 4)
|
2025-08-12 10:15:05 +02:00
|
|
|
|
: null;
|
|
|
|
|
|
if (status === null) {
|
|
|
|
|
|
return res.status(400).json({ error: "Status non fourni." });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
const page = clamp(req.query.page, 1, 1_000_000);
|
|
|
|
|
|
const limit = clamp(req.query.limit, 1, 500) || 100;
|
2025-08-12 10:15:05 +02:00
|
|
|
|
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." });
|
|
|
|
|
|
}
|
2025-08-11 18:43:12 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-12 11:32:02 +02:00
|
|
|
|
// --------------------------- STATS -----------------------------
|
|
|
|
|
|
autopostRouter.get('/stats', async (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
2025-08-12 11:32:02 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-23 14:27:18 +02:00
|
|
|
|
// Redirection accès direct GET /edit/:id
|
2025-03-12 12:47:51 +01:00
|
|
|
|
autopostRouter.get('/edit/:id', (req, res) => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
res.redirect("/autopost/");
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.use('/autopost', autopostRouter);
|
|
|
|
|
|
|
2025-08-14 19:27:48 +02:00
|
|
|
|
// Endpoint santé simple
|
|
|
|
|
|
app.get('/healthz', (req, res) => res.type('text').send('ok'));
|
|
|
|
|
|
|
2025-03-12 12:47:51 +01:00
|
|
|
|
app.listen(port, () => {
|
2025-08-14 19:27:48 +02:00
|
|
|
|
console.log(`Serveur démarré sur http://localhost:${port}/autopost`);
|
2025-03-12 12:47:51 +01:00
|
|
|
|
});
|