400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
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 { exec as execCb } from "child_process";
|
|
import { promisify } from "util";
|
|
import { parse as parseCookies } from "cookie";
|
|
const execAsync = promisify(execCb);
|
|
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<boolean> {
|
|
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<number> {
|
|
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<string, number>();
|
|
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"));
|
|
}
|
|
});
|
|
|
|
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
|
|
|
|
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
|
|
app.post("/api/db-backup", async (req, res) => {
|
|
// Vérifier l'auth JWT
|
|
const { verifyToken } = await import("../auth");
|
|
const cookies = parseCookies(req.headers.cookie || "");
|
|
const token = cookies.auth_token;
|
|
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
|
|
const user = verifyToken(token);
|
|
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
|
|
|
|
try {
|
|
const dbUrl = new URL(process.env.DATABASE_URL || "");
|
|
const host = dbUrl.hostname;
|
|
const port = dbUrl.port || "3306";
|
|
const username = dbUrl.username;
|
|
const password = dbUrl.password;
|
|
const database = dbUrl.pathname.slice(1);
|
|
|
|
// Créer le dossier backups/
|
|
const backupDir = path.resolve("backups");
|
|
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
const fileName = `backup-${database}-${timestamp}.sql`;
|
|
const filePath = path.join(backupDir, fileName);
|
|
|
|
// Construire la commande mysqldump
|
|
const sslFlag = dbUrl.searchParams.get("ssl-mode") === "DISABLED" ? "" : "--ssl-mode=REQUIRED";
|
|
const cmd = `mysqldump ${sslFlag} -h "${host}" -P ${port} -u "${username}" --password="${password}" "${database}" > "${filePath}"`;
|
|
|
|
console.log(`[Backup] Generating dump for database ${database}...`);
|
|
await execAsync(cmd);
|
|
console.log(`[Backup] Dump saved to ${filePath}`);
|
|
|
|
// Retourner le fichier en téléchargement
|
|
const encodedName = encodeURIComponent(fileName);
|
|
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
|
res.setHeader("Content-Type", "application/sql");
|
|
res.sendFile(filePath, (err) => {
|
|
if (err) console.error("[Backup] Error sending file:", err);
|
|
});
|
|
} catch (err: any) {
|
|
console.error("[Backup] Error:", err.message);
|
|
res.status(500).json({ error: "Erreur lors de la génération du dump : " + err.message });
|
|
}
|
|
});
|
|
|
|
// Télécharger une sauvegarde existante
|
|
app.get("/api/db-backup/:filename", async (req, res) => {
|
|
const { verifyToken } = await import("../auth");
|
|
const cookies2 = parseCookies(req.headers.cookie || "");
|
|
const token = cookies2.auth_token;
|
|
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
|
|
const user = verifyToken(token);
|
|
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
|
|
|
|
const fileName = path.basename(req.params.filename);
|
|
const filePath = path.join(path.resolve("backups"), fileName);
|
|
if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; }
|
|
const encodedName = encodeURIComponent(fileName);
|
|
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
|
res.setHeader("Content-Type", "application/sql");
|
|
res.sendFile(filePath);
|
|
});
|
|
|
|
app.post("/api/web-import/push-invoice", async (req, res) => {
|
|
try {
|
|
const { apiToken, fileName, fileBase64, mimeType } = req.body;
|
|
if (!apiToken || !fileName || !fileBase64) {
|
|
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
|
|
return;
|
|
}
|
|
const { getWebImportSourceByToken, getImportSettingsByUser, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile } = await import('../db');
|
|
const source = await getWebImportSourceByToken(apiToken);
|
|
if (!source) {
|
|
res.status(401).json({ error: "Token invalide" });
|
|
return;
|
|
}
|
|
const pdfBuffer = Buffer.from(fileBase64, 'base64');
|
|
const fileMime = mimeType || 'application/pdf';
|
|
// Stocker le fichier source en DB
|
|
const sourceFile = await createSourceFile({
|
|
userId: source.userId,
|
|
fileName,
|
|
fileKey: `web-import/${source.userId}/${Date.now()}-${fileName}`,
|
|
fileUrl: '',
|
|
});
|
|
const importSettings = await getImportSettingsByUser(source.userId);
|
|
const aiSettings = {
|
|
aiProvider: importSettings?.aiProvider || 'manus',
|
|
mistralApiKey: importSettings?.mistralApiKey || undefined,
|
|
manusForgeApiUrl: importSettings?.manusForgeApiUrl || undefined,
|
|
manusForgeApiKey: importSettings?.manusForgeApiKey || undefined,
|
|
};
|
|
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
|
|
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, 'mistral-large-latest', undefined, aiSettings);
|
|
let imported = 0;
|
|
let duplicates = 0;
|
|
for (const inv of extractResult.invoices || []) {
|
|
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
|
|
if (blacklisted) { duplicates++; continue; }
|
|
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
|
|
if (dup) { duplicates++; continue; }
|
|
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
|
|
imported++;
|
|
}
|
|
await updateWebImportSourceStatus(source.id, 'success', imported, true);
|
|
res.json({ success: true, imported, duplicates, total: (extractResult.invoices || []).length });
|
|
} catch (err: any) {
|
|
console.error('[WebImport] Erreur push-invoice:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// 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);
|