434 lines
18 KiB
TypeScript
434 lines
18 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 { parse as parseCookies } from "cookie";
|
||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||
import { registerOAuthRoutes } from "./oauth";
|
||
import { appRouter } from "../routers";
|
||
import { createContext } from "./context";
|
||
import { getSessionCookieOptions } from "./cookies";
|
||
import { serveStatic, setupVite } from "./vite";
|
||
import { getAllUsers, getImportSettingsByUser, getUserByAzureAdId, getUserByEmail, getUserSettings, upsertUser } from "../db";
|
||
import { startEmailImportService } from "../emailImportService";
|
||
import { startFolderImportService } from "../folderImportService";
|
||
import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken } from "../auth";
|
||
import { createDatabaseBackup } from "../databaseBackup";
|
||
import { generateStorageKey, localStorageDelete, localStoragePut } from "../localStorage";
|
||
import { calculateFileSha256 } from "../fileFingerprint";
|
||
|
||
const MAX_WEB_IMPORT_BYTES = 20 * 1024 * 1024;
|
||
|
||
/** Returns the signed local session or sends the appropriate HTTP error. */
|
||
function requireAuthenticatedUser(req: express.Request, res: express.Response) {
|
||
const token = parseCookies(req.headers.cookie || "").auth_token;
|
||
const user = token ? verifyToken(token) : null;
|
||
if (!user) {
|
||
res.status(401).json({ error: "Non authentifié" });
|
||
return null;
|
||
}
|
||
return user;
|
||
}
|
||
|
||
/** Restricts a sensitive endpoint to administrators. */
|
||
function requireAdmin(req: express.Request, res: express.Response) {
|
||
const user = requireAuthenticatedUser(req, res);
|
||
if (!user) return null;
|
||
if (user.role !== "admin") {
|
||
res.status(403).json({ error: "Accès réservé aux administrateurs" });
|
||
return null;
|
||
}
|
||
return user;
|
||
}
|
||
|
||
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) => {
|
||
if (!requireAuthenticatedUser(req, res)) return;
|
||
// 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) => {
|
||
if (!requireAuthenticatedUser(req, res)) return;
|
||
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) => {
|
||
if (!requireAuthenticatedUser(req, res)) return;
|
||
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, {
|
||
...getSessionCookieOptions(req),
|
||
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) => {
|
||
if (!requireAdmin(req, res)) return;
|
||
|
||
try {
|
||
const backupDir = path.resolve("backups");
|
||
const backup = await createDatabaseBackup(process.env.DATABASE_URL, backupDir);
|
||
console.log(`[Backup] Dump saved to ${backup.filePath} (${backup.size} bytes)`);
|
||
|
||
// Retourner le fichier en téléchargement
|
||
const encodedName = encodeURIComponent(backup.fileName);
|
||
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
||
res.setHeader("Content-Type", "application/sql");
|
||
res.sendFile(backup.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: "La sauvegarde n’a pas pu être générée. Consultez les journaux serveur." });
|
||
}
|
||
});
|
||
|
||
// Télécharger une sauvegarde existante
|
||
app.get("/api/db-backup/:filename", async (req, res) => {
|
||
if (!requireAdmin(req, res)) 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 } = req.body;
|
||
if (typeof apiToken !== "string" || typeof fileName !== "string" || typeof fileBase64 !== "string") {
|
||
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
|
||
return;
|
||
}
|
||
const safeFileName = path.basename(fileName);
|
||
if (!safeFileName.toLowerCase().endsWith(".pdf")) {
|
||
res.status(400).json({ error: "Seuls les fichiers PDF sont acceptés" });
|
||
return;
|
||
}
|
||
if (Buffer.byteLength(fileBase64, "utf8") > Math.ceil(MAX_WEB_IMPORT_BYTES * 1.34)) {
|
||
res.status(413).json({ error: "Le fichier dépasse la taille maximale autorisée" });
|
||
return;
|
||
}
|
||
|
||
const { getWebImportSourceByToken, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile, getSourceFileByContentHash } = await import('../db');
|
||
const source = await getWebImportSourceByToken(apiToken);
|
||
if (!source) {
|
||
res.status(401).json({ error: "Token invalide" });
|
||
return;
|
||
}
|
||
const pdfBuffer = Buffer.from(fileBase64, 'base64');
|
||
if (pdfBuffer.length === 0 || pdfBuffer.length > MAX_WEB_IMPORT_BYTES || !pdfBuffer.subarray(0, 4).equals(Buffer.from("%PDF"))) {
|
||
res.status(400).json({ error: "Le contenu reçu n’est pas un PDF valide" });
|
||
return;
|
||
}
|
||
|
||
const contentHash = calculateFileSha256(pdfBuffer);
|
||
const existingSource = await getSourceFileByContentHash(contentHash);
|
||
if (existingSource) {
|
||
await updateWebImportSourceStatus(source.id, "success", 0, true);
|
||
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
|
||
return;
|
||
}
|
||
|
||
// Stocker d'abord le PDF de façon persistante, comme les autres sources d'import.
|
||
const storageKey = generateStorageKey(source.userId, safeFileName);
|
||
const { url: fileUrl } = await localStoragePut(storageKey, pdfBuffer, "application/pdf");
|
||
let sourceFile;
|
||
try {
|
||
sourceFile = await createSourceFile({
|
||
userId: source.userId,
|
||
fileName: safeFileName,
|
||
fileKey: storageKey,
|
||
fileUrl,
|
||
contentHash,
|
||
});
|
||
} catch (error: any) {
|
||
if (error?.code === "ER_DUP_ENTRY" || error?.errno === 1062) {
|
||
await localStorageDelete(storageKey).catch(() => undefined);
|
||
await updateWebImportSourceStatus(source.id, "success", 0, true);
|
||
res.json({ success: true, imported: 0, duplicates: 1, total: 1 });
|
||
return;
|
||
}
|
||
throw error;
|
||
}
|
||
const userSettings = await getUserSettings(source.userId);
|
||
const aiSettings = {
|
||
aiProvider: userSettings?.aiProvider || "manus",
|
||
mistralApiKey: userSettings?.mistralApiKey || undefined,
|
||
manusForgeApiUrl: userSettings?.manusForgeApiUrl || undefined,
|
||
manusForgeApiKey: userSettings?.manusForgeApiKey || undefined,
|
||
geminiApiKey: userSettings?.geminiApiKey || undefined,
|
||
};
|
||
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
|
||
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, userSettings?.llmModel || "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: "L’import web a échoué. Consultez les journaux serveur." });
|
||
}
|
||
});
|
||
|
||
// 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
|
||
}
|
||
|
||
// Vitest ne doit jamais démarrer un serveur HTTP.
|
||
if (!process.env.VITEST) {
|
||
startServer().catch(console.error);
|
||
}
|