/** * Manus Dashboard - Webhook Handler (CI/CD Gitea) * Route : POST /api/webhook/gitea * Écoute les événements push de Gitea et déclenche le déploiement automatique. */ const express = require('express'); const crypto = require('crypto'); const { exec } = require('child_process'); const path = require('path'); const fs = require('fs'); const router = express.Router(); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''; const APPS_BASE_PATH = process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps'; const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh'; const LOG_DIR = '/var/log/manus-deploy'; // Mapping dépôt Gitea -> nom du dossier application sur le serveur const REPO_TO_APP_MAP = { 'itinova-contacts': 'itinova-contacts', 'itinova-podcasts': 'itinova-podcasts', 'veille-reglementaire': 'veille-reglementaire', 'itinova-vehicle-exchange': 'itinova-vehicle-exchange', 'manus-dashboard': 'manus-dashboard', 'itinova-budget-si': 'itinova-budget-si', }; // Déploiements en cours (évite les doubles déclenchements) const deployingApps = new Set(); function verifyGiteaSignature(req) { if (!WEBHOOK_SECRET) { console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.'); return true; } const signature = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || ''; console.log('[Webhook DEBUG] All headers:', JSON.stringify(Object.keys(req.headers))); console.log('[Webhook DEBUG] x-gitea-signature:', req.headers['x-gitea-signature'] || 'ABSENT'); console.log('[Webhook DEBUG] rawBody available:', !!req.rawBody, 'length:', req.rawBody ? req.rawBody.length : 0); // Utiliser rawBody si disponible (préservé avant express.json), sinon re-sérialiser const bodyStr = req.rawBody ? req.rawBody.toString() : JSON.stringify(req.body); const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET).update(bodyStr).digest('hex'); const expected = `sha256=${hmac}`; const received = signature.startsWith('sha256=') ? signature : `sha256=${signature}`; try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); } catch { return false; } } function runDeploy(appName, branch, commitHash, committer, broadcast) { if (deployingApps.has(appName)) { console.log(`[Webhook] Déploiement déjà en cours pour ${appName}. Ignoré.`); return; } deployingApps.add(appName); console.log(`[Webhook] Déclenchement déploiement: ${appName} (branch: ${branch}, commit: ${commitHash})`); if (broadcast) { broadcast({ type: 'deploy_started', data: { app: appName, commit: commitHash, branch, committer } }); } const env = { ...process.env, APPS_BASE_PATH }; const cmd = `bash ${DEPLOY_SCRIPT} ${appName} recette`; let output = ''; const child = exec(cmd, { env, timeout: 600000 }); child.stdout.on('data', (d) => { output += d; process.stdout.write(`[${appName}] ${d}`); }); child.stderr.on('data', (d) => { output += d; process.stderr.write(`[${appName}] ERR: ${d}`); }); child.on('close', (code) => { deployingApps.delete(appName); const status = code === 0 ? 'success' : 'failed'; console.log(`[Webhook] Déploiement ${status} pour ${appName} (exit: ${code})`); if (broadcast) { broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } }); } try { fs.mkdirSync(LOG_DIR, { recursive: true }); fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({ app: appName, branch, commit: commitHash, committer, status, exitCode: code, timestamp: new Date().toISOString(), output: output.slice(-3000), }, null, 2)); } catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); } }); } // POST /api/webhook/gitea router.post('/gitea', (req, res) => { if (!verifyGiteaSignature(req)) { console.warn('[Webhook] Signature invalide. Requête rejetée.'); return res.status(401).json({ error: 'Signature invalide' }); } const event = req.headers['x-gitea-event'] || 'unknown'; if (event !== 'push') { return res.status(200).json({ message: `Événement ${event} ignoré` }); } const payload = req.body; const repoName = payload?.repository?.name || ''; const branch = (payload?.ref || '').replace('refs/heads/', ''); const commitHash = (payload?.after || '').slice(0, 8) || 'unknown'; const committer = payload?.pusher?.login || payload?.pusher?.name || 'unknown'; console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`); const appName = REPO_TO_APP_MAP[repoName]; if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non configuré` }); if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` }); res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer }); // Récupérer la fonction broadcast si disponible globalement const broadcastFn = global.wsBroadcast || null; setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn)); }); // GET /api/webhook/status/:appName router.get('/status/:appName', (req, res) => { const { appName } = req.params; const statusFile = path.join(LOG_DIR, `${appName}-last-deploy.json`); if (!fs.existsSync(statusFile)) return res.status(404).json({ error: 'Aucun déploiement enregistré' }); try { const status = JSON.parse(fs.readFileSync(statusFile, 'utf8')); res.json({ ...status, isDeploying: deployingApps.has(appName) }); } catch { res.status(500).json({ error: 'Erreur lecture statut' }); } }); // GET /api/webhook/status router.get('/status', (req, res) => { try { fs.mkdirSync(LOG_DIR, { recursive: true }); const files = fs.readdirSync(LOG_DIR).filter(f => f.endsWith('-last-deploy.json')); const statuses = files.map(f => { try { const d = JSON.parse(fs.readFileSync(path.join(LOG_DIR, f), 'utf8')); return { ...d, isDeploying: deployingApps.has(d.app) }; } catch { return null; } }).filter(Boolean); res.json({ deployments: statuses, currentlyDeploying: [...deployingApps] }); } catch { res.status(500).json({ error: 'Erreur lecture statuts' }); } }); module.exports = router;