Maj export
This commit is contained in:
@@ -11,6 +11,19 @@ const DB_PATH = process.env.DB_PATH || path.resolve(__dirname, '../../data/crowd
|
||||
// Ensure data directory exists
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
// ── Pending restore (déclenché par POST /api/admin/exports/:filename/restore) ─
|
||||
// Si un fichier .pending-restore existe, on l'applique avant d'ouvrir la DB.
|
||||
const PENDING_RESTORE = DB_PATH + '.pending-restore';
|
||||
if (fs.existsSync(PENDING_RESTORE)) {
|
||||
try {
|
||||
fs.copyFileSync(PENDING_RESTORE, DB_PATH);
|
||||
fs.unlinkSync(PENDING_RESTORE);
|
||||
console.log('[restore] Base de données restaurée avec succès.');
|
||||
} catch (e) {
|
||||
console.error('[restore] Échec de la restauration :', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* autoExport.js — Job planifié à 3h00 du matin.
|
||||
* Génère un export complet (DB + assets) et le conserve dans DATA_DIR/exports/.
|
||||
* Résultat tracé dans job_logs (job_name = 'auto_export').
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import db from '../db/index.js';
|
||||
import { createZip } from '../utils/zip.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? path.resolve(process.env.DATA_DIR)
|
||||
: path.resolve(__dirname, '../../../data');
|
||||
const exportsDir = path.join(dataDir, 'exports');
|
||||
const MAX_EXPORTS = 10;
|
||||
const JOB_NAME = 'auto_export';
|
||||
|
||||
function writeLog({ status, nbChanges = 0, details = null, errorMsg = null }) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO job_logs (job_name, status, nb_changes, details, error_msg)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(JOB_NAME, status, nbChanges, details, errorMsg);
|
||||
} catch (e) {
|
||||
console.error('[autoExport] Impossible d\'écrire dans job_logs :', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function purgeOldExports() {
|
||||
try {
|
||||
fs.mkdirSync(exportsDir, { recursive: true });
|
||||
const files = fs.readdirSync(exportsDir)
|
||||
.filter(f => f.endsWith('.zip'))
|
||||
.map(f => ({ f, mtime: fs.statSync(path.join(exportsDir, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
for (const { f } of files.slice(MAX_EXPORTS)) {
|
||||
fs.unlinkSync(path.join(exportsDir, f));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[autoExport] Erreur purge :', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAutoExport() {
|
||||
const tmpDb = path.join(os.tmpdir(), `cl-auto-export-${Date.now()}.db`);
|
||||
const startedAt = Date.now();
|
||||
console.log('[autoExport] Démarrage de l\'export automatique…');
|
||||
|
||||
try {
|
||||
await db.backup(tmpDb);
|
||||
const dbData = fs.readFileSync(tmpDb);
|
||||
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
+ `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const filename = `crowdlending-export-${ts}.zip`;
|
||||
|
||||
const entries = [];
|
||||
entries.push({
|
||||
name: 'manifest.json',
|
||||
data: JSON.stringify({
|
||||
version: '1.0', app: 'crowdlending',
|
||||
exported_at: now.toISOString(),
|
||||
type: 'full-export',
|
||||
source: 'auto_export_job',
|
||||
}, null, 2),
|
||||
});
|
||||
entries.push({ name: 'crowdlending.db', data: dbData });
|
||||
|
||||
let assetCount = 0;
|
||||
for (const subdir of ['logos', 'icons']) {
|
||||
const dir = path.join(dataDir, subdir);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
const fpath = path.join(dir, f);
|
||||
if (fs.statSync(fpath).isFile()) {
|
||||
entries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fpath) });
|
||||
assetCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zipBuf = createZip(entries);
|
||||
fs.mkdirSync(exportsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(exportsDir, filename), zipBuf);
|
||||
purgeOldExports();
|
||||
|
||||
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Durée : ${elapsed}s`;
|
||||
writeLog({ status: 'ok', nbChanges: 1, details });
|
||||
console.log(`[autoExport] Export terminé en ${elapsed}s → ${filename}`);
|
||||
} catch (e) {
|
||||
console.error('[autoExport] Erreur :', e.message);
|
||||
writeLog({ status: 'error', nbChanges: 0, errorMsg: e.message });
|
||||
} finally {
|
||||
if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Planifie runAutoExport() tous les jours à 3h00 locale (sans dérive). */
|
||||
export function startAutoExportJob() {
|
||||
function msUntil3am() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(3, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
function scheduleDailyRun() {
|
||||
const delay = msUntil3am();
|
||||
setTimeout(async () => {
|
||||
await runAutoExport();
|
||||
scheduleDailyRun();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
scheduleDailyRun();
|
||||
console.log(`[autoExport] Job planifié — prochaine exécution dans ${Math.round(msUntil3am() / 60000)} min (3h00)`);
|
||||
}
|
||||
+266
-5
@@ -1,10 +1,25 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||
import { runAutoExport } from '../jobs/autoExport.js';
|
||||
import { audit } from '../utils/audit.js';
|
||||
import multer from 'multer';
|
||||
import { createZip, readZip } from '../utils/zip.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? path.resolve(process.env.DATA_DIR)
|
||||
: path.resolve(__dirname, '../../../data');
|
||||
const dbPath = process.env.DB_PATH
|
||||
? path.resolve(process.env.DB_PATH)
|
||||
: path.resolve(__dirname, '../../data/crowdlending.db');
|
||||
|
||||
// ── Helpers similarité de noms ──────────────────────────────────────────── */
|
||||
|
||||
@@ -36,9 +51,10 @@ function similarity(a, b) {
|
||||
|
||||
const SIMILARITY_THRESHOLD = 0.80;
|
||||
|
||||
// Registre des jobs disponibles (nom → fonction synchrone)
|
||||
// Registre des jobs disponibles (nom → fonction)
|
||||
const JOBS = {
|
||||
auto_statut_retard: checkStatutsRetard,
|
||||
auto_export: runAutoExport,
|
||||
};
|
||||
|
||||
const router = Router();
|
||||
@@ -302,18 +318,18 @@ router.post('/plateformes-orphelines/:id/lier', (req, res, next) => {
|
||||
|
||||
/* -- Execution manuelle d'un job ----------------------------------------- */
|
||||
|
||||
router.post('/jobs/:name/run', (req, res, next) => {
|
||||
router.post('/jobs/:name/run', async (req, res, next) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const fn = JOBS[name];
|
||||
if (!fn) throw new HttpError(404, `Job inconnu : ${name}`);
|
||||
|
||||
const nbChanges = fn();
|
||||
const lastLog = db.prepare(
|
||||
await fn();
|
||||
const lastLog = db.prepare(
|
||||
'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1'
|
||||
).get(name);
|
||||
|
||||
res.json({ ok: true, nb_changes: nbChanges, log: lastLog });
|
||||
res.json({ ok: true, nb_changes: lastLog?.nb_changes ?? 0, log: lastLog });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -408,4 +424,249 @@ router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/* ── Export complet prod → dev ────────────────────────────────────────────── */
|
||||
|
||||
const exportsDir = path.join(dataDir, 'exports');
|
||||
const MAX_EXPORTS = 10;
|
||||
|
||||
/** Retourne la liste des exports triée du plus récent au plus ancien */
|
||||
function listExportFiles() {
|
||||
fs.mkdirSync(exportsDir, { recursive: true });
|
||||
return fs.readdirSync(exportsDir)
|
||||
.filter(f => f.endsWith('.zip'))
|
||||
.map(f => {
|
||||
const stat = fs.statSync(path.join(exportsDir, f));
|
||||
return { filename: f, size: stat.size, created_at: stat.mtime.toISOString() };
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
}
|
||||
|
||||
/** Supprime les exports les plus anciens au-delà de MAX_EXPORTS */
|
||||
function purgeOldExports() {
|
||||
const files = listExportFiles();
|
||||
for (const f of files.slice(MAX_EXPORTS)) {
|
||||
fs.unlinkSync(path.join(exportsDir, f.filename));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/export-full
|
||||
* Génère un export ZIP, le sauvegarde sur disque, et le retourne au navigateur.
|
||||
*/
|
||||
router.get('/export-full', async (req, res, next) => {
|
||||
const tmpDb = path.join(os.tmpdir(), `cl-backup-${Date.now()}.db`);
|
||||
try {
|
||||
await db.backup(tmpDb);
|
||||
const dbData = fs.readFileSync(tmpDb);
|
||||
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
+ `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const filename = `crowdlending-export-${ts}.zip`;
|
||||
|
||||
const entries = [];
|
||||
entries.push({
|
||||
name: 'manifest.json',
|
||||
data: JSON.stringify({
|
||||
version: '1.0',
|
||||
app: 'crowdlending',
|
||||
exported_at: now.toISOString(),
|
||||
type: 'full-export',
|
||||
}, null, 2),
|
||||
});
|
||||
entries.push({ name: 'crowdlending.db', data: dbData });
|
||||
|
||||
for (const subdir of ['logos', 'icons']) {
|
||||
const dir = path.join(dataDir, subdir);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
const fpath = path.join(dir, f);
|
||||
if (fs.statSync(fpath).isFile()) {
|
||||
entries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fpath) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zipBuf = createZip(entries);
|
||||
|
||||
// Sauvegarde sur disque + purge
|
||||
fs.mkdirSync(exportsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(exportsDir, filename), zipBuf);
|
||||
purgeOldExports();
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(zipBuf);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
} finally {
|
||||
if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/admin/exports — liste des exports stockés sur le serveur */
|
||||
router.get('/exports', (req, res, next) => {
|
||||
try {
|
||||
res.json(listExportFiles());
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
const exportUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 200 * 1024 * 1024 }, // 200 Mo max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) cb(null, true);
|
||||
else cb(new HttpError(400, 'Fichier ZIP attendu'));
|
||||
},
|
||||
});
|
||||
|
||||
/** POST /api/admin/exports/upload — import d'un export depuis le client */
|
||||
router.post('/exports/upload', exportUpload.single('file'), (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) throw new HttpError(400, 'Aucun fichier reçu');
|
||||
|
||||
// Validation : le ZIP doit contenir un manifest.json avec type 'full-export'
|
||||
let entries;
|
||||
try { entries = readZip(req.file.buffer); }
|
||||
catch { throw new HttpError(400, 'Archive ZIP invalide ou corrompue'); }
|
||||
|
||||
const manifestEntry = entries.find(e => e.name === 'manifest.json');
|
||||
if (!manifestEntry) throw new HttpError(400, "Archive invalide : manifest.json introuvable");
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); }
|
||||
catch { throw new HttpError(400, 'manifest.json illisible'); }
|
||||
if (manifest.type !== 'full-export') {
|
||||
throw new HttpError(400, `Type d'archive incorrect : "${manifest.type}" (attendu : "full-export")`);
|
||||
}
|
||||
|
||||
// Sanitisation du nom de fichier
|
||||
let filename = path.basename(req.file.originalname);
|
||||
if (!filename.endsWith('.zip')) filename += '.zip';
|
||||
|
||||
// Évite les collisions de nom
|
||||
const dest = path.join(exportsDir, filename);
|
||||
if (fs.existsSync(dest)) {
|
||||
const ts = Date.now();
|
||||
filename = filename.replace(/\.zip$/, `-${ts}.zip`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(exportsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(exportsDir, filename), req.file.buffer);
|
||||
purgeOldExports();
|
||||
|
||||
res.json({ ok: true, filename, exports: listExportFiles() });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** GET /api/admin/exports/:filename — téléchargement d'un export stocké */
|
||||
router.get('/exports/:filename', (req, res, next) => {
|
||||
try {
|
||||
const filename = path.basename(req.params.filename); // sécurité : pas de path traversal
|
||||
if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide');
|
||||
const fpath = path.join(exportsDir, filename);
|
||||
if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable');
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(fs.readFileSync(fpath));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** DELETE /api/admin/exports/:filename — suppression d'un export */
|
||||
router.delete('/exports/:filename', (req, res, next) => {
|
||||
try {
|
||||
const filename = path.basename(req.params.filename);
|
||||
if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide');
|
||||
const fpath = path.join(exportsDir, filename);
|
||||
if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable');
|
||||
fs.unlinkSync(fpath);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/exports/:filename/restore
|
||||
* Restaure l'environnement depuis un export stocké :
|
||||
* 1. Sauvegarde l'état courant dans exports/ (filet de sécurité)
|
||||
* 2. Copie les assets (logos, icons) immédiatement
|
||||
* 3. Écrit la nouvelle DB dans {DB_PATH}.pending-restore
|
||||
* 4. Répond au client, puis redémarre le processus (process.exit)
|
||||
* → Docker/nodemon relance le serveur qui applique le pending-restore au démarrage
|
||||
*/
|
||||
router.post('/exports/:filename/restore', async (req, res, next) => {
|
||||
const tmpDb = path.join(os.tmpdir(), `cl-pre-restore-${Date.now()}.db`);
|
||||
try {
|
||||
const filename = path.basename(req.params.filename);
|
||||
if (!filename.endsWith('.zip')) throw new HttpError(400, 'Nom de fichier invalide');
|
||||
const fpath = path.join(exportsDir, filename);
|
||||
if (!fs.existsSync(fpath)) throw new HttpError(404, 'Export introuvable');
|
||||
|
||||
// Lecture et validation du ZIP
|
||||
const zipBuf = fs.readFileSync(fpath);
|
||||
let entries;
|
||||
try { entries = readZip(zipBuf); }
|
||||
catch { throw new HttpError(400, 'Archive ZIP invalide ou corrompue'); }
|
||||
const manifestEntry = entries.find(e => e.name === 'manifest.json');
|
||||
if (!manifestEntry) throw new HttpError(400, 'manifest.json introuvable dans l\'archive');
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); }
|
||||
catch { throw new HttpError(400, 'manifest.json illisible'); }
|
||||
if (manifest.type !== 'full-export') throw new HttpError(400, 'Type d\'archive incorrect');
|
||||
|
||||
const dbEntry = entries.find(e => e.name === 'crowdlending.db');
|
||||
if (!dbEntry) throw new HttpError(400, 'crowdlending.db introuvable dans l\'archive');
|
||||
|
||||
// 1. Sauvegarde de sécurité de l'état courant
|
||||
await db.backup(tmpDb);
|
||||
const backupEntries = [];
|
||||
backupEntries.push({
|
||||
name: 'manifest.json',
|
||||
data: JSON.stringify({
|
||||
version: '1.0', app: 'crowdlending',
|
||||
exported_at: new Date().toISOString(),
|
||||
type: 'full-export',
|
||||
note: 'pre-restore-backup',
|
||||
}, null, 2),
|
||||
});
|
||||
backupEntries.push({ name: 'crowdlending.db', data: fs.readFileSync(tmpDb) });
|
||||
for (const subdir of ['logos', 'icons']) {
|
||||
const dir = path.join(dataDir, subdir);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
const fp = path.join(dir, f);
|
||||
if (fs.statSync(fp).isFile()) backupEntries.push({ name: `${subdir}/${f}`, data: fs.readFileSync(fp) });
|
||||
}
|
||||
}
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const now = new Date();
|
||||
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
fs.mkdirSync(exportsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(exportsDir, `pre-restore-backup-${ts}.zip`), createZip(backupEntries));
|
||||
purgeOldExports();
|
||||
|
||||
// 2. Remplacement des assets (logos + icons) — safe à faire en live
|
||||
for (const subdir of ['logos', 'icons']) {
|
||||
const dir = path.join(dataDir, subdir);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Vidage du dossier existant
|
||||
for (const f of fs.readdirSync(dir)) fs.unlinkSync(path.join(dir, f));
|
||||
// Écriture des nouveaux fichiers
|
||||
for (const entry of entries.filter(e => e.name.startsWith(`${subdir}/`) && !e.name.endsWith('/'))) {
|
||||
fs.writeFileSync(path.join(dir, path.basename(entry.name)), entry.data);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Écriture du pending-restore (appliqué par db/index.js au prochain démarrage)
|
||||
fs.writeFileSync(dbPath + '.pending-restore', dbEntry.data);
|
||||
|
||||
// 4. Réponse puis redémarrage
|
||||
res.json({ ok: true, backup: `pre-restore-backup-${ts}.zip` });
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
} finally {
|
||||
if (fs.existsSync(tmpDb)) fs.unlinkSync(tmpDb);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -32,6 +32,7 @@ import iconsRouter from './routes/icons.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { requireAuth, requireAdmin } from './middleware/auth.js';
|
||||
import { startAutoStatutJob } from './jobs/autoStatut.js';
|
||||
import { startAutoExportJob } from './jobs/autoExport.js';
|
||||
import adminRouter from './routes/admin.js';
|
||||
import invitationsRouter from './routes/invitations.js';
|
||||
import auditLogsRouter from './routes/auditLogs.js';
|
||||
@@ -134,4 +135,6 @@ app.use(errorHandler);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Crowdlending API listening on http://localhost:${PORT}`);
|
||||
startAutoStatutJob();
|
||||
startAutoExportJob();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user