Files
itinova-budget-si/server/salairesHttp.ts

186 lines
7.3 KiB
TypeScript

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." });
}
});
}