import "dotenv/config"; import express from "express"; import { createServer } from "http"; import net from "net"; import path from "path"; import fs from "fs"; import archiver from "archiver"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOAuthRoutes } from "./oauth"; import { appRouter } from "../routers"; import { createContext } from "./context"; import { serveStatic, setupVite } from "./vite"; import { getAllUsers, getUserByAzureAdId, getUserByEmail, upsertUser } from "../db"; import { startEmailImportService } from "../emailImportService"; import { startFolderImportService } from "../folderImportService"; import { getImportSettingsByUser } from "../db"; import { handleAzureCallback, isAzureAdConfigured, generateToken } from "../auth"; function isPortAvailable(port: number): Promise { return new Promise(resolve => { const server = net.createServer(); server.listen(port, () => { server.close(() => resolve(true)); }); server.on("error", () => resolve(false)); }); } async function findAvailablePort(startPort: number = 3000): Promise { for (let port = startPort; port < startPort + 20; port++) { if (await isPortAvailable(port)) { return port; } } throw new Error(`No available port found starting from ${startPort}`); } async function startServer() { const app = express(); const server = createServer(app); // Configure body parser with larger size limit for file uploads app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); // OAuth callback under /api/oauth/callback registerOAuthRoutes(app); // Serve local storage files app.use("/storage", express.static("storage")); // Route de téléchargement forcé du PDF annoté BAP // Accepte les chemins avec sous-dossiers : /api/download-bap/2026-04/filename.pdf // ou via query param pdfPath : /api/download-bap/file.pdf?pdfPath=/storage/2026-04/file.pdf app.get("/api/download-bap", (req, res) => { // Mode 1 : query param pdfPath (chemin complet depuis /storage/...) const pdfPath = req.query.pdfPath as string | undefined; if (!pdfPath) { res.status(400).json({ error: "Paramètre pdfPath manquant" }); return; } // Sécurité : s'assurer que le chemin est bien dans le dossier storage const normalized = path.normalize(pdfPath).replace(/^\/+/, ''); if (normalized.startsWith('..') || !normalized.startsWith('storage')) { res.status(403).json({ error: "Accès refusé" }); return; } const storagePath = path.resolve(normalized); if (!fs.existsSync(storagePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; } // Utiliser le nom de fichier fourni par le client (format Date - Fournisseur - N°Facture.pdf) const customFilename = req.query.filename as string | undefined; const fallbackFilename = path.basename(storagePath); const finalFilename = customFilename ? customFilename : fallbackFilename; // RFC 5987 : utiliser filename* pour les caractères non-ASCII const encodedFilename = encodeURIComponent(finalFilename); res.setHeader("Content-Disposition", `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`); res.setHeader("Content-Type", "application/pdf"); res.sendFile(storagePath); }); // Compat. ancienne route avec :filename (sans sous-dossier) app.get("/api/download-bap/:filename", (req, res) => { const filename = path.basename(req.params.filename); // Chercher dans tous les sous-dossiers de storage const storageRoot = path.resolve("storage"); let found: string | null = null; try { const subdirs = fs.readdirSync(storageRoot); for (const sub of subdirs) { const candidate = path.join(storageRoot, sub, filename); if (fs.existsSync(candidate)) { found = candidate; break; } } // Aussi essayer directement dans storage/ const direct = path.join(storageRoot, filename); if (!found && fs.existsSync(direct)) found = direct; } catch { /* ignore */ } if (!found) { res.status(404).json({ error: "Fichier introuvable" }); return; } res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(filename)}"`); res.setHeader("Content-Type", "application/pdf"); res.sendFile(found); }); // Route de téléchargement groupé ZIP des PDFs annotés BAP // POST /api/download-bap-zip avec body { files: Array<{ pdfPath: string, filename: string }> } app.post("/api/download-bap-zip", (req, res) => { const files: Array<{ pdfPath: string; filename: string }> = req.body.files || []; if (!files.length) { res.status(400).json({ error: "Aucun fichier spécifié" }); return; } // Vérifier que tous les chemins sont dans storage/ const resolvedFiles: Array<{ absPath: string; filename: string }> = []; for (const f of files) { const normalized = path.normalize(f.pdfPath).replace(/^\/+/, ''); if (normalized.startsWith('..') || !normalized.startsWith('storage')) continue; const absPath = path.resolve(normalized); if (fs.existsSync(absPath)) { resolvedFiles.push({ absPath, filename: f.filename }); } } if (!resolvedFiles.length) { res.status(404).json({ error: "Aucun fichier trouvé" }); return; } const zipFilename = `BAP_export_${new Date().toLocaleDateString('fr-CA')}.zip`; const encodedZip = encodeURIComponent(zipFilename); res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Disposition", `attachment; filename="${encodedZip}"; filename*=UTF-8''${encodedZip}`); const archive = archiver('zip', { zlib: { level: 6 } }); archive.on('error', (err) => { console.error('ZIP error:', err); res.destroy(); }); archive.pipe(res); // Gérer les doublons de noms de fichiers const usedNames = new Map(); for (const { absPath, filename } of resolvedFiles) { const base = filename.replace(/\.pdf$/i, ''); const count = usedNames.get(base) || 0; usedNames.set(base, count + 1); const finalName = count === 0 ? filename : `${base} (${count}).pdf`; archive.file(absPath, { name: finalName }); } archive.finalize(); }); // ============= AZURE AD OAUTH2 CALLBACK ============= app.get("/api/auth/azure/callback", async (req, res) => { const code = req.query.code as string | undefined; const error = req.query.error as string | undefined; if (error) { console.error("[Azure AD] Erreur OAuth:", error, req.query.error_description); res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refusée")}`); return; } if (!code) { res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant")); return; } if (!isAzureAdConfigured()) { res.redirect("/login?error=" + encodeURIComponent("Azure AD non configuré")); return; } try { const azureUser = await handleAzureCallback(code); // Chercher l'utilisateur par azureAdId ou par email let user = await getUserByAzureAdId(azureUser.azureAdId); if (!user) { user = await getUserByEmail(azureUser.email); } if (!user) { // Créer l'utilisateur automatiquement await upsertUser({ email: azureUser.email, name: azureUser.name, azureAdId: azureUser.azureAdId, loginMethod: "azure-ad", isActive: 1, role: "user", }); user = await getUserByEmail(azureUser.email); } else { // Mettre à jour l'azureAdId si manquant if (!user.azureAdId) { await upsertUser({ email: user.email, azureAdId: azureUser.azureAdId, loginMethod: user.loginMethod, }); } } if (!user) { res.redirect("/login?error=" + encodeURIComponent("Impossible de créer le compte")); return; } if (user.isActive === 0) { res.redirect("/login?error=" + encodeURIComponent("Compte inactif")); return; } // Générer le token JWT et poser le cookie const token = generateToken(user); res.cookie("auth_token", token, { httpOnly: true, secure: false, sameSite: "lax", path: "/", maxAge: 7 * 24 * 60 * 60 * 1000, }); console.log(`[Azure AD] Connexion réussie pour ${user.email}`); res.redirect("/"); } catch (err: any) { console.error("[Azure AD] Erreur callback:", err.message); res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft")); } }); // tRPC API app.use( "/api/trpc", createExpressMiddleware({ router: appRouter, createContext, }) ); // development mode uses Vite, production mode uses static files if (process.env.NODE_ENV === "development") { await setupVite(app, server); } else { serveStatic(app); } const preferredPort = parseInt(process.env.PORT || "3000"); const port = await findAvailablePort(preferredPort); if (port !== preferredPort) { console.log(`Port ${preferredPort} is busy, using port ${port} instead`); } server.listen(port, () => { console.log(`Server running on http://localhost:${port}/`); }); // Redémarrer automatiquement les services actifs (IMAP, dossier) après redémarrage du serveur setTimeout(async () => { try { const users = await getAllUsers(); for (const user of users) { const settings = await getImportSettingsByUser(user.id); if (!settings) continue; if (settings.emailImportEnabled === 1) { console.log(`[AutoRestart] Restarting email import service for user ${user.id}...`); await startEmailImportService(user.id).catch(e => console.error(`[AutoRestart] Failed to restart email service for user ${user.id}:`, e.message) ); } if ((settings as any).autoImportEnabled === 1) { console.log(`[AutoRestart] Restarting folder import service for user ${user.id}...`); await startFolderImportService(user.id).catch(e => console.error(`[AutoRestart] Failed to restart folder service for user ${user.id}:`, e.message) ); } } } catch (e: any) { console.error('[AutoRestart] Error during service auto-restart:', e.message); } }, 5000); // Attendre 5s que le serveur soit prêt } startServer().catch(console.error);