Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
186 lines
7.3 KiB
TypeScript
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." });
|
|
}
|
|
});
|
|
}
|