const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { authenticate, authMiddleware } = require('./auth'); const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker'); const { getRepo, getCommits, getBranches, listRepos } = require('./gitea'); const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck'); const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator'); const config = require('./config'); // Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent) cleanupOrphanedDeployments(); const router = express.Router(); // Configuration multer pour l'upload de fichiers ZIP const uploadDir = '/tmp/dashboard-uploads'; if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const upload = multer({ dest: uploadDir, limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max fileFilter: (req, file, cb) => { if (file.mimetype === 'application/zip' || file.mimetype === 'application/x-zip-compressed' || file.mimetype === 'application/octet-stream' || file.originalname.endsWith('.zip')) { cb(null, true); } else { cb(new Error('Seuls les fichiers ZIP sont acceptés'), false); } }, }); // ============ AUTH ROUTES ============ router.post('/auth/login', async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); } const token = await authenticate(username, password); if (!token) { return res.status(401).json({ error: 'Identifiants invalides' }); } // Set cookie res.cookie('dashboard_token', token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000, // 24h }); res.json({ token, username }); } catch (err) { res.status(500).json({ error: err.message }); } }); router.post('/auth/logout', (req, res) => { res.clearCookie('dashboard_token'); res.json({ message: 'Déconnecté' }); }); router.get('/auth/me', authMiddleware, (req, res) => { res.json({ username: req.user.username, role: req.user.role }); }); // ============ APPS ROUTES ============ // Liste des applications avec leur statut router.get('/apps', authMiddleware, async (req, res) => { try { const statuses = getAllStatuses(); if (statuses.length === 0) { // Première requête, lancer un check const results = await checkAllApps(); return res.json(results); } res.json(statuses); } catch (err) { res.status(500).json({ error: err.message }); } }); // Détail d'une application router.get('/apps/:id', authMiddleware, async (req, res) => { try { const appConfig = config.apps.find((a) => a.id === req.params.id); if (!appConfig) { return res.status(404).json({ error: 'Application non trouvée' }); } const status = getAppStatus(appConfig.id); const containerInfo = await getContainerInfo(appConfig.containerName); // Récupérer les infos Gitea let giteaInfo = null; let commits = []; try { giteaInfo = await getRepo(appConfig.giteaOwner, appConfig.giteaRepo); commits = await getCommits(appConfig.giteaOwner, appConfig.giteaRepo, 10); } catch (e) { // Gitea peut ne pas être disponible } res.json({ ...status, container: containerInfo, gitea: giteaInfo ? { fullName: giteaInfo.full_name, description: giteaInfo.description, defaultBranch: giteaInfo.default_branch, updatedAt: giteaInfo.updated_at, cloneUrl: giteaInfo.clone_url, htmlUrl: giteaInfo.html_url, } : null, commits, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Forcer un health check router.post('/apps/:id/check', authMiddleware, async (req, res) => { try { const results = await checkAllApps(); const result = results.find((r) => r.id === req.params.id); res.json(result || { error: 'Application non trouvée' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Verrou anti-concurrence par application const deployLocks = new Map(); const DEPLOY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes max // Nettoyage automatique des verrous bloqués toutes les minutes setInterval(() => { const now = Date.now(); for (const [appId, lockTime] of deployLocks.entries()) { if (now - lockTime > DEPLOY_TIMEOUT_MS) { deployLocks.delete(appId); const logs = getDeploymentLogs(appId); logs.filter(l => l.status === 'running').forEach(l => { l.status = 'failed'; l.message = 'Déploiement interrompu automatiquement (timeout 10 min)'; }); } } }, 60 * 1000); // Redéployer une application router.post('/apps/:id/deploy', authMiddleware, async (req, res) => { try { const appConfig = config.apps.find((a) => a.id === req.params.id); if (!appConfig) { return res.status(404).json({ error: 'Application non trouvée' }); } // Vérifier si un déploiement est déjà en cours pour cette app if (deployLocks.has(appConfig.id)) { const lockAge = Math.round((Date.now() - deployLocks.get(appConfig.id)) / 1000); return res.status(409).json({ error: `Un déploiement est déjà en cours pour ${appConfig.name} (depuis ${lockAge}s)`, status: 'running' }); } // Poser le verrou deployLocks.set(appConfig.id, Date.now()); // Ajouter un log de début et récupérer son id pour le mettre à jour plus tard const deployEntry = addDeploymentLog(appConfig.id, { status: 'running', message: `Déploiement de ${appConfig.name} en cours...`, logs: '', user: req.user.username, }); // Lancer le redéploiement en arrière-plan res.json({ message: `Redéploiement de ${appConfig.name} lancé`, status: 'running' }); // Timeout de sécurité : si le déploiement dépasse 10 min, on le marque failed const timeoutHandle = setTimeout(() => { if (deployLocks.has(appConfig.id)) { deployLocks.delete(appConfig.id); updateDeploymentLog(deployEntry.id, { status: 'failed', message: `Déploiement de ${appConfig.name} interrompu (timeout 10 min)`, }); } }, DEPLOY_TIMEOUT_MS); try { // Exécuter le redéploiement const result = await redeployApp(appConfig); clearTimeout(timeoutHandle); // Mettre à jour l'entrée existante (pas de doublon "running" + "success") updateDeploymentLog(deployEntry.id, { status: result.success ? 'success' : 'failed', message: result.success ? `Redéploiement de ${appConfig.name} réussi en ${result.duration}s` : `Redéploiement de ${appConfig.name} échoué`, logs: result.logs, duration: result.duration, }); // Relancer un health check après le déploiement setTimeout(() => checkAllApps(), 5000); } finally { // Toujours libérer le verrou deployLocks.delete(appConfig.id); } } catch (err) { deployLocks.delete(req.params.id); addDeploymentLog(req.params.id, { status: 'failed', message: `Erreur: ${err.message}`, logs: '', user: req.user.username, }); } }); // Logs d'un conteneur router.get('/apps/:id/logs', authMiddleware, async (req, res) => { try { const appConfig = config.apps.find((a) => a.id === req.params.id); if (!appConfig) { return res.status(404).json({ error: 'Application non trouvée' }); } const tail = parseInt(req.query.tail) || 100; const logs = await getContainerLogs(appConfig.containerName, tail); res.json({ logs }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ CREATE APP ROUTE ============ // Récupérer les stacks disponibles router.get('/apps-config/stacks', authMiddleware, (req, res) => { res.json(getAvailableStacks()); }); // Créer une nouvelle application router.post('/apps-config/create', authMiddleware, upload.single('zipFile'), async (req, res) => { try { const { name, description, subdomain, stack, port, needsDb, dbType, sourceType, giteaRepoUrl } = req.body; // Validation if (!name || !subdomain || !stack || !sourceType) { return res.status(400).json({ error: 'Champs requis manquants: name, subdomain, stack, sourceType', }); } // Vérifier que le subdomain est valide const subdomainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; if (!subdomainRegex.test(subdomain)) { return res.status(400).json({ error: 'Le sous-domaine ne peut contenir que des lettres minuscules, chiffres et tirets', }); } // Vérifier que l'app n'existe pas déjà const existingApp = config.apps.find((a) => a.id === subdomain); if (existingApp) { return res.status(409).json({ error: `Une application avec l'identifiant "${subdomain}" existe déjà`, }); } // Vérifier le fichier ZIP si sourceType === 'zip' if (sourceType === 'zip' && !req.file) { return res.status(400).json({ error: 'Un fichier ZIP est requis pour le type de source "zip"', }); } // Vérifier l'URL Gitea si sourceType === 'gitea' if (sourceType === 'gitea' && !giteaRepoUrl) { return res.status(400).json({ error: 'L\'URL du dépôt Gitea est requise pour le type de source "gitea"', }); } // Log de début de création et récupérer l'id pour mise à jour ultérieure const logLines = []; const createEntry = addDeploymentLog(subdomain, { status: 'running', message: `Création de l'application ${name} en cours...`, logs: '', user: req.user.username, }); // Répondre immédiatement res.json({ message: `Création de l'application ${name} lancée`, status: 'running', appId: subdomain, }); // Lancer la création en arrière-plan try { const result = await createApplication( { name, description, subdomain, stack, port: port ? parseInt(port) : null, needsDb: needsDb === 'true' || needsDb === true, dbType: dbType || 'mysql', sourceType, giteaRepoUrl, zipFilePath: req.file ? req.file.path : null, }, (line) => logLines.push(line) ); updateDeploymentLog(createEntry.id, { status: 'success', message: `Application ${name} créée et déployée avec succès`, logs: logLines.join('\n'), }); // Relancer un health check setTimeout(() => checkAllApps(), 10000); } catch (createErr) { updateDeploymentLog(createEntry.id, { status: 'failed', message: `Création de ${name} échouée: ${createErr.message}`, logs: logLines.join('\n'), }); } } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ DEPLOYMENT LOGS ============ router.get('/deployments', authMiddleware, (req, res) => { const appId = req.query.appId || null; res.json(getDeploymentLogs(appId)); }); // ============ GITEA ROUTES ============ router.get('/gitea/repos', authMiddleware, async (req, res) => { try { const repos = await listRepos(); res.json( repos.map((r) => ({ id: r.id, name: r.name, fullName: r.full_name, description: r.description, private: r.private, htmlUrl: r.html_url, cloneUrl: r.clone_url, updatedAt: r.updated_at, language: r.language, size: r.size, })) ); } catch (err) { res.status(500).json({ error: err.message }); } }); router.get('/gitea/repos/:owner/:repo/commits', authMiddleware, async (req, res) => { try { const commits = await getCommits(req.params.owner, req.params.repo, 20); res.json(commits); } catch (err) { res.status(500).json({ error: err.message }); } }); router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res) => { try { const branches = await getBranches(req.params.owner, req.params.repo); res.json(branches); } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ PUBLIC STATUS ROUTE (sans authentification) ============ // Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées router.get('/public/status', async (req, res) => { try { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); const statuses = getAllStatuses(); const publicStatus = statuses.map((app) => ({ id: app.id, name: app.name, status: app.status, // 'online' | 'offline' | 'error' | 'unknown' containerRunning: app.container ? app.container.running : false, })); res.json(publicStatus); } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ PUBLIC APPS ROUTE (sans authentification) ============ // Utilisé par le portail applicatif pour construire les tuiles dynamiquement // Ne bloque jamais : retourne les apps avec status 'unknown' si les health checks ne sont pas encore disponibles router.get('/public/apps', (req, res) => { try { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // getAllStatuses() retourne un tableau vide si aucun check n'a encore été effectué // On ne déclenche PAS checkAllApps() ici pour éviter de bloquer la réponse const statuses = getAllStatuses(); const publicApps = config.apps .filter((a) => a.category !== 'INFRA') // Exclure les apps infra (portail, dashboard) .map((a) => { const status = statuses.find((s) => s.id === a.id); return { id: a.id, name: a.name, category: a.category || 'SANTINOVA', image: a.image || ('images/' + a.id + '.png'), urls: a.urls || {}, status: status ? status.status : 'unknown', containerRunning: status && status.container ? status.container.running : false, }; }); res.json(publicApps); } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ DOCKER ROUTES ============ router.get('/docker/containers', authMiddleware, async (req, res) => { try { const containers = await listContainers(); res.json(containers); } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ SYSTEM INFO ============ router.get('/system/info', authMiddleware, async (req, res) => { try { const containers = await listContainers(); res.json({ totalApps: config.apps.length, totalContainers: containers.length, runningContainers: containers.filter((c) => c.state === 'running').length, giteaUrl: config.gitea.url, serverTime: new Date().toISOString(), }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ CONTAINER CONTROL (start/stop/restart) ============ router.post('/apps/:id/start', authMiddleware, async (req, res) => { try { const app = config.apps.find((a) => a.id === req.params.id); if (!app) return res.status(404).json({ error: 'Application non trouvée' }); const containerName = app.containerName || app.id; const result = await startContainer(containerName, app.id); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } }); router.post('/apps/:id/stop', authMiddleware, async (req, res) => { try { const app = config.apps.find((a) => a.id === req.params.id); if (!app) return res.status(404).json({ error: 'Application non trouvée' }); const containerName = app.containerName || app.id; const result = await stopContainer(containerName); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } }); router.post('/apps/:id/restart', authMiddleware, async (req, res) => { try { const app = config.apps.find((a) => a.id === req.params.id); if (!app) return res.status(404).json({ error: 'Application non trouvée' }); const containerName = app.containerName || app.id; const result = await restartContainer(containerName); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } }); // ============ SERVER METRICS ============ router.get('/system/metrics', authMiddleware, async (req, res) => { try { const metrics = await getServerMetrics(); res.json(metrics); } catch (err) { res.status(500).json({ error: err.message }); } }); module.exports = router; // ===== INVENTAIRE DES APPLICATIONS ===== const { exec } = require('child_process'); const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000'; const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org'; const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org'; const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin'; const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!'; const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!'; const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null; // INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json) function getInventoryApps() { return config.apps.map((a) => ({ id: a.id, name: a.name, repoName: a.giteaRepo || a.id, })); } function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) { return new Promise((resolve) => { const authHeader = token ? `-H "Authorization: token ${token}"` : `-u "${GITEA_USER_INV}:${pass}"`; const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`; exec(cmd, { timeout: 7000 }, (err, stdout) => { if (err || !stdout) return resolve({ present: false, url: null, version: null }); try { const data = JSON.parse(stdout); if (!data.id) return resolve({ present: false, url: null, version: null }); // Récupérer le dernier commit const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`; exec(cmd2, { timeout: 7000 }, (err2, stdout2) => { let version = null; try { const commits = JSON.parse(stdout2 || '[]'); if (commits.length > 0) { const c = commits[0]; const sha = c.sha ? c.sha.substring(0, 7) : null; const date = c.commit && c.commit.author && c.commit.author.date ? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null; version = sha && date ? `${sha} (${date})` : sha; } } catch(e) {} const displayUrl = publicBaseUrl || baseUrl; resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version }); }); } catch(e) { resolve({ present: false, url: null, version: null }); } }); }); } router.get('/inventory', authMiddleware, async (req, res) => { try { const owner = GITEA_USER_INV; const INVENTORY_APPS = getInventoryApps(); const results = await Promise.all( INVENTORY_APPS.map(async (app) => { const [repoRecette, repoProd] = await Promise.all([ curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC), curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD), ]); return { ...app, repoRecette, repoProd }; }) ); res.json(results); } catch (err) { console.error('Erreur /inventory:', err.message); res.status(500).json({ error: err.message }); } }); ;