Checkpoint: Fenêtre administrateur Salaires finalisée : import multipart limité, chiffrement AES-256-GCM avant stockage persistant hors conteneur, métadonnées/index métier minimisés en base, consultation PDF déchiffrée côté serveur avec no-store, filtres année/mois et comparaison M-1 expliquée. Réindexation sûre des archives partielles sans écrasement du PDF. Validation réelle sur deux liasses mensuelles : index partiel signalé sans invention de données, comparaison affichée et lecture après redémarrage. 55 tests Vitest, TypeScript et build de production validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 1m54s
This commit is contained in:
185
server/salairesHttp.ts
Normal file
185
server/salairesHttp.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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." });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user