feat: gateway sécurisé, miroir de schéma automatique et sync incrémentale

- Schéma Hostinger recopié automatiquement depuis PG Debian (colonnes, PK,
  index) : plus d'init.sql maintenu à la main (corrige articles.artcentrale,
  mvtart.no_id et les 12 tables manquantes)
- Stratégies : full transactionnel (DELETE + INSERT, garde-fou de volume) et
  window (signature mensuelle + 10 derniers jours) pour les grosses tables
- Attente de la fin de la sync Debian (_run), pg_try_advisory_lock,
  cron 07:30 Europe/Paris, force honoré, alertes webhook
- Dashboard : basic auth, CSP sans script inline, plus d'innerHTML,
  port 3002 non publié (accès via Nginx Proxy Manager)
- Secrets hors du compose (.env), frps épinglé 0.71.0 avec TLS forcé,
  allowPorts et additionalScopes
- Suppression de sync.sh, tables.js, utils.js, init.sql ; README de déploiement

L'ancien mot de passe de la base `post` figure dans l'historique git :
il doit être changé sur le serveur (voir README).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-17 11:41:29 +02:00
1 parent f8575b9ff2
commit ea084034b9
26 files changed
+2767 -922

No files matched your search

+51 -76
View File
@@ -1,84 +1,59 @@
'use strict';
/**
* Point d'entrée du service ff_sync_hostinger.
*
* node index.js → dashboard + cron (SYNC_CRON, fuseau SYNC_TZ)
* node index.js --once → une exécution normale puis sortie
* node index.js --force → une exécution forcée (garde-fou volume ignoré,
* tables "fenêtre" rechargées entièrement) puis sortie
*
* Le verrou consultatif PostgreSQL empêche toute exécution concurrente,
* y compris entre le service et une commande `docker exec … node index.js --once`.
*/
const cron = require('node-cron');
const { getSrc, getDst, closeAll } = require('./db');
const { batchUpsert, fullRefresh, logSync } = require('./utils');
const { TABLES } = require('./tables');
const {
startDashboard, registerSyncFn,
setSyncRunning, isSyncRunning,
setProgress, clearProgress,
} = require('./server');
const { config } = require('./config');
const log = require('./logger');
const { closeAll } = require('./db');
const { runSync } = require('./sync');
const { startDashboard, registerSyncFn, registerNextRunFn } = require('./server');
const forceMode = process.argv.includes('--force');
const args = process.argv.slice(2);
const force = args.includes('--force');
const once = force || args.includes('--once');
async function syncTable(src, dst, table, pk) {
const { rows } = await src.query(`SELECT * FROM ${table}`);
if (!rows.length) {
await logSync(dst, table, 0, 'ok');
console.log(`[${table}] 0 lignes (table vide)`);
return 0;
}
let count;
if (pk.length === 0) {
count = await fullRefresh(dst, table, rows);
} else {
count = await batchUpsert(dst, table, rows, pk);
}
await logSync(dst, table, count, 'ok');
console.log(`[${table}] ${count} lignes`);
return count;
}
async function syncAll(force = false) {
if (isSyncRunning()) {
console.log('Sync déjà en cours — ignorée.');
return;
}
setSyncRunning(true);
clearProgress();
const start = Date.now();
console.log(`\n=== SYNC START ${new Date().toISOString()} ===`);
const src = getSrc();
const dst = getDst();
try {
for (let i = 0; i < TABLES.length; i++) {
const { name, pk } = TABLES[i];
setProgress(name, i + 1);
console.log(`[${i + 1}/${TABLES.length}] Sync ${name}…`);
try {
await syncTable(src, dst, name, pk);
} catch (err) {
await logSync(dst, name, 0, 'error', err.message).catch(() => {});
console.error(`[${name}] ERREUR: ${err.message}`);
}
}
} finally {
setSyncRunning(false);
clearProgress();
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`=== SYNC DONE en ${elapsed}s ===\n`);
}
registerSyncFn(syncAll);
if (forceMode) {
console.log('Mode force : sync complète en cours...');
syncAll(true)
.then(() => closeAll())
.then(() => process.exit(0))
.catch(err => {
console.error('Erreur fatale sync:', err.message);
if (once) {
runSync({ force, trigger: 'cli' })
.then(async r => {
await closeAll();
process.exit(r.skipped || r.status === 'ok' ? 0 : 1);
})
.catch(async err => {
log.error(`Erreur fatale : ${err.message}`);
await closeAll();
process.exit(1);
});
} else {
startDashboard();
console.log('Service sync Hostinger démarré — cron: 0 7 * * * (7h00 chaque nuit)');
cron.schedule('0 7 * * *', () => {
syncAll(false).catch(err => console.error('Erreur cron sync:', err.message));
});
if (!cron.validate(config.cron)) {
log.error(`SYNC_CRON invalide : "${config.cron}"`);
process.exit(1);
}
registerSyncFn(runSync);
const task = cron.schedule(
config.cron,
() => runSync({ force: false, trigger: 'cron' }).catch(err => log.error(`Sync cron : ${err.message}`)),
{ timezone: config.timeZone, name: 'ff-sync', noOverlap: true },
);
registerNextRunFn(() => task.getNextRun());
const server = startDashboard();
log.info(`Service sync Hostinger démarré — cron "${config.cron}" (${config.timeZone}), prochaine exécution ${task.getNextRun()?.toISOString() ?? '?'}`);
const shutdown = sig => {
log.info(`${sig} reçu — arrêt (une transaction en cours est annulée par PostgreSQL)`);
task.stop();
server.close();
closeAll().finally(() => process.exit(0));
setTimeout(() => process.exit(0), 5000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}