import crypto from "node:crypto"; import type { Express, Request, Response } from "express"; import multer from "multer"; import { sdk } from "./_core/sdk"; import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, replaceSalaireLiasseIndex } from "./db"; import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto"; import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser"; import { extractPayrollPdfText } from "./payrollPdfText"; import { storageGetBuffer, storagePut } from "./storage"; const MAX_PDF_BYTES = 25 * 1024 * 1024; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_PDF_BYTES, files: 1 }, }); function parsePeriod(value: unknown, min: number, max: number): number | null { if (typeof value !== "string" || !/^\d+$/.test(value)) return null; const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null; } function isPdf(buffer: Buffer): boolean { return buffer.subarray(0, 5).toString("ascii") === "%PDF-"; } function safeUploadFilename(value: string | undefined): string { const normalized = (value ?? "liasse.pdf").replace(/[\\/\0]/g, "").trim(); return normalized.slice(0, 255) || "liasse.pdf"; } async function getAdminUser(req: Request, res: Response) { let user; try { user = await sdk.authenticateRequest(req); } catch { res.status(401).json({ error: "Authentification requise." }); return null; } if (user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux administrateurs." }); return null; } return user; } /** * Routes REST limitées aux transferts binaires. Toutes les données métier sont * lues via tRPC ; aucune URL de stockage ne quitte ce module serveur. */ export function registerSalairesHttpRoutes(app: Express) { app.post("/api/salaires/liasses", upload.single("file"), async (req, res) => { const user = await getAdminUser(req, res); if (!user) return; const annee = parsePeriod(req.body?.annee, 2020, 2100); const mois = parsePeriod(req.body?.mois, 1, 12); const file = req.file; if (!annee || !mois || !file || !isPdf(file.buffer)) { res.status(400).json({ error: "Transmettez une période valide et un fichier PDF conforme." }); return; } const alreadyImported = await getSalaireLiasseByPeriod(annee, mois); if (alreadyImported) { res.status(409).json({ error: "Une liasse est déjà archivée pour cette période. Elle ne peut pas être écrasée." }); return; } // pdf-parse est exécuté en mémoire : le texte des bulletins n'est ni journalisé // ni envoyé au navigateur. Un échec d'analyse laisse une archive consultable. let extraction: PayrollExtractionResult = { bulletins: [], statutExtraction: "a_controler" as const, erreurExtraction: "La liasse a été archivée, mais son index doit être contrôlé manuellement.", }; try { extraction = extractPayrollBulletins(await extractPayrollPdfText(file.buffer)); } catch { // Le message reste générique afin de ne pas divulguer de détail du contenu PDF. } try { const encrypted = encryptPayrollPdf(file.buffer); const archive = await storagePut( `salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`, encrypted.ciphertext, "application/octet-stream", ); const persisted = await createSalaireLiasse({ annee, mois, nomFichier: safeUploadFilename(file.originalname), stockageKey: archive.key, empreinteSha256: crypto.createHash("sha256").update(file.buffer).digest("hex"), tailleOctets: file.buffer.length, ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, importePar: user.id, statutExtraction: extraction.statutExtraction, erreurExtraction: extraction.erreurExtraction, bulletins: extraction.bulletins, }); res.status(201).json({ id: persisted.liasseId, bulletins: persisted.bulletins, statutExtraction: extraction.statutExtraction, avertissement: extraction.erreurExtraction, }); } catch (error) { console.error("[Salaires] Échec de l'archivage d'une liasse", error instanceof Error ? error.message : "erreur inconnue"); res.status(500).json({ error: "L'archivage sécurisé de la liasse a échoué. Aucun enregistrement n'a été créé." }); } }); app.post("/api/salaires/liasses/:id/reindex", async (req, res) => { const user = await getAdminUser(req, res); if (!user) return; const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER); if (!id) { res.status(400).json({ error: "Référence de liasse invalide." }); return; } const liasse = await getSalaireLiasseById(id); if (!liasse) { res.status(404).json({ error: "Liasse introuvable." }); return; } try { const ciphertext = await storageGetBuffer(liasse.stockageKey); const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 }); if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature"); const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf)); if (extraction.bulletins.length === 0) { res.status(422).json({ error: "Aucun bulletin n'a pu être indexé. L'archive et l'index existant sont conservés." }); return; } const result = await replaceSalaireLiasseIndex(id, extraction); res.json({ bulletins: result.bulletins, statutExtraction: extraction.statutExtraction, avertissement: extraction.erreurExtraction }); } catch (error) { console.error("[Salaires] Échec de la réindexation d'une liasse", error instanceof Error ? error.message : "erreur inconnue"); res.status(503).json({ error: "La liasse ne peut pas être réindexée pour le moment. Son archive reste inchangée." }); } }); app.get("/api/salaires/liasses/:id/pdf", async (req, res) => { const user = await getAdminUser(req, res); if (!user) return; const id = parsePeriod(req.params.id, 1, Number.MAX_SAFE_INTEGER); if (!id) { res.status(400).json({ error: "Référence de liasse invalide." }); return; } const liasse = await getSalaireLiasseById(id); if (!liasse) { res.status(404).json({ error: "Liasse introuvable." }); return; } try { const ciphertext = await storageGetBuffer(liasse.stockageKey); const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64, }); if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature"); res.set({ "Content-Type": "application/pdf", "Content-Length": String(pdf.length), "Content-Disposition": `inline; filename="bulletins-${liasse.annee}-${String(liasse.mois).padStart(2, "0")}.pdf"`, "Cache-Control": "no-store, private", Pragma: "no-cache", "X-Content-Type-Options": "nosniff", }); res.send(pdf); } catch (error) { console.error("[Salaires] Échec de la consultation d'une liasse", error instanceof Error ? error.message : "erreur inconnue"); res.status(503).json({ error: "Le document ne peut pas être consulté pour le moment." }); } }); }