Checkpoint: Audit technique : suppression des composants, scripts et dépendances inutilisés ; chargement différé des pages ; génération de sauvegardes SQL robuste par lots ; centralisation des contrôles d’accès ; sécurisation des cookies Azure et imports web ; documentation de maintenance et 5 tests de non-régression ajoutés.
This commit is contained in:
30
server/_core/cookies.test.ts
Normal file
30
server/_core/cookies.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Request } from "express";
|
||||
import { getSessionCookieOptions } from "./cookies";
|
||||
|
||||
function requestFor(protocol: "http" | "https", forwardedProto?: string): Request {
|
||||
return {
|
||||
protocol,
|
||||
headers: forwardedProto ? { "x-forwarded-proto": forwardedProto } : {},
|
||||
} as Request;
|
||||
}
|
||||
|
||||
describe("getSessionCookieOptions", () => {
|
||||
it("utilise des cookies sécurisés derrière le proxy HTTPS", () => {
|
||||
expect(getSessionCookieOptions(requestFor("http", "https"))).toMatchObject({
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("reste compatible avec l’environnement HTTP local", () => {
|
||||
expect(getSessionCookieOptions(requestFor("http"))).toMatchObject({
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { CookieOptions, Request } from "express";
|
||||
|
||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function isIpAddress(host: string) {
|
||||
// Basic IPv4 check and IPv6 presence detection.
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
|
||||
return host.includes(":");
|
||||
}
|
||||
|
||||
/** Detects HTTPS after direct access or a reverse proxy such as Traefik. */
|
||||
function isSecureRequest(req: Request) {
|
||||
if (req.protocol === "https") return true;
|
||||
|
||||
@@ -24,25 +17,13 @@ function isSecureRequest(req: Request) {
|
||||
export function getSessionCookieOptions(
|
||||
req: Request
|
||||
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
|
||||
// const hostname = req.hostname;
|
||||
// const shouldSetDomain =
|
||||
// hostname &&
|
||||
// !LOCAL_HOSTS.has(hostname) &&
|
||||
// !isIpAddress(hostname) &&
|
||||
// hostname !== "127.0.0.1" &&
|
||||
// hostname !== "::1";
|
||||
|
||||
// const domain =
|
||||
// shouldSetDomain && !hostname.startsWith(".")
|
||||
// ? `.${hostname}`
|
||||
// : shouldSetDomain
|
||||
// ? hostname
|
||||
// : undefined;
|
||||
const secure = isSecureRequest(req);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "none",
|
||||
secure: isSecureRequest(req),
|
||||
// Browsers reject SameSite=None without Secure; use Lax for local HTTP.
|
||||
sameSite: secure ? "none" : "lax",
|
||||
secure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,20 +5,43 @@ import net from "net";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import archiver from "archiver";
|
||||
import { exec as execCb } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { parse as parseCookies } from "cookie";
|
||||
const execAsync = promisify(execCb);
|
||||
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, getUserByAzureAdId, getUserByEmail, upsertUser } from "../db";
|
||||
import { getAllUsers, getImportSettingsByUser, getUserByAzureAdId, getUserByEmail, getUserSettings, upsertUser } from "../db";
|
||||
import { startEmailImportService } from "../emailImportService";
|
||||
import { startFolderImportService } from "../folderImportService";
|
||||
import { getImportSettingsByUser } from "../db";
|
||||
import { handleAzureCallback, isAzureAdConfigured, generateToken } from "../auth";
|
||||
import { handleAzureCallback, isAzureAdConfigured, generateToken, verifyToken } from "../auth";
|
||||
import { createDatabaseBackup } from "../databaseBackup";
|
||||
import { generateStorageKey, localStoragePut } from "../localStorage";
|
||||
|
||||
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 => {
|
||||
@@ -54,6 +77,7 @@ async function startServer() {
|
||||
// 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) {
|
||||
@@ -84,6 +108,7 @@ async function startServer() {
|
||||
|
||||
// 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");
|
||||
@@ -110,6 +135,7 @@ async function startServer() {
|
||||
// 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é" });
|
||||
@@ -213,10 +239,7 @@ async function startServer() {
|
||||
// Générer le token JWT et poser le cookie
|
||||
const token = generateToken(user);
|
||||
res.cookie("auth_token", token, {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
...getSessionCookieOptions(req),
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -232,94 +255,29 @@ async function startServer() {
|
||||
|
||||
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
|
||||
app.post("/api/db-backup", async (req, res) => {
|
||||
// Vérifier l'auth JWT
|
||||
const { verifyToken } = await import("../auth");
|
||||
const cookies = parseCookies(req.headers.cookie || "");
|
||||
const token = cookies.auth_token;
|
||||
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
|
||||
const user = verifyToken(token);
|
||||
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
try {
|
||||
const dbUrl = new URL(process.env.DATABASE_URL || "");
|
||||
const host = dbUrl.hostname;
|
||||
const port = dbUrl.port || "3306";
|
||||
const username = dbUrl.username;
|
||||
const password = dbUrl.password;
|
||||
const database = dbUrl.pathname.slice(1);
|
||||
|
||||
// Créer le dossier backups/
|
||||
const backupDir = path.resolve("backups");
|
||||
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const fileName = `backup-${database}-${timestamp}.sql`;
|
||||
const filePath = path.join(backupDir, fileName);
|
||||
|
||||
// Dump SQL via mysql2 (pas besoin de mysqldump)
|
||||
console.log(`[Backup] Generating SQL dump for database ${database}...`);
|
||||
const mysql = await import("mysql2/promise");
|
||||
const sslRequired = !dbUrl.searchParams.get("ssl-mode")?.includes("DISABLED");
|
||||
const conn = await mysql.createConnection({
|
||||
host, port: parseInt(port), user: username, password: decodeURIComponent(password),
|
||||
database, ssl: sslRequired ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
|
||||
let sql = `-- Backup généré le ${new Date().toISOString()}\n-- Base : ${database}\nSET FOREIGN_KEY_CHECKS=0;\n\n`;
|
||||
|
||||
// Lister les tables
|
||||
const [tables] = await conn.query<any[]>(`SHOW TABLES`);
|
||||
const tableNames: string[] = tables.map((r: any) => Object.values(r)[0] as string);
|
||||
|
||||
for (const table of tableNames) {
|
||||
// CREATE TABLE
|
||||
const [createRows] = await conn.query<any[]>(`SHOW CREATE TABLE \`${table}\``);
|
||||
const createSql: string = createRows[0]['Create Table'] || createRows[0][`Create Table`];
|
||||
sql += `\n-- Table: ${table}\nDROP TABLE IF EXISTS \`${table}\`;\n${createSql};\n\n`;
|
||||
|
||||
// INSERT DATA
|
||||
const [rows] = await conn.query<any[]>(`SELECT * FROM \`${table}\``);
|
||||
if (rows.length > 0) {
|
||||
const cols = Object.keys(rows[0]).map(c => `\`${c}\``).join(", ");
|
||||
const values = rows.map(row =>
|
||||
"(" + Object.values(row).map(v =>
|
||||
v === null ? "NULL" :
|
||||
v instanceof Date ? `'${v.toISOString().replace('T', ' ').replace('Z', '')}'` :
|
||||
typeof v === "number" ? v :
|
||||
`'${String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
|
||||
).join(", ") + ")"
|
||||
).join(",\n");
|
||||
sql += `INSERT INTO \`${table}\` (${cols}) VALUES\n${values};\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
sql += `\nSET FOREIGN_KEY_CHECKS=1;\n-- Fin du dump\n`;
|
||||
await conn.end();
|
||||
|
||||
fs.writeFileSync(filePath, sql, "utf8");
|
||||
console.log(`[Backup] Dump saved to ${filePath} (${(sql.length / 1024).toFixed(1)} Ko)`);
|
||||
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(fileName);
|
||||
const encodedName = encodeURIComponent(backup.fileName);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
|
||||
res.setHeader("Content-Type", "application/sql");
|
||||
res.sendFile(filePath, (err) => {
|
||||
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: "Erreur lors de la génération du dump : " + 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) => {
|
||||
const { verifyToken } = await import("../auth");
|
||||
const cookies2 = parseCookies(req.headers.cookie || "");
|
||||
const token = cookies2.auth_token;
|
||||
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
|
||||
const user = verifyToken(token);
|
||||
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const fileName = path.basename(req.params.filename);
|
||||
const filePath = path.join(path.resolve("backups"), fileName);
|
||||
@@ -332,35 +290,52 @@ async function startServer() {
|
||||
|
||||
app.post("/api/web-import/push-invoice", async (req, res) => {
|
||||
try {
|
||||
const { apiToken, fileName, fileBase64, mimeType } = req.body;
|
||||
if (!apiToken || !fileName || !fileBase64) {
|
||||
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 { getWebImportSourceByToken, getImportSettingsByUser, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile } = await import('../db');
|
||||
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 } = await import('../db');
|
||||
const source = await getWebImportSourceByToken(apiToken);
|
||||
if (!source) {
|
||||
res.status(401).json({ error: "Token invalide" });
|
||||
return;
|
||||
}
|
||||
const pdfBuffer = Buffer.from(fileBase64, 'base64');
|
||||
const fileMime = mimeType || 'application/pdf';
|
||||
// Stocker le fichier source en DB
|
||||
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;
|
||||
}
|
||||
|
||||
// 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");
|
||||
const sourceFile = await createSourceFile({
|
||||
userId: source.userId,
|
||||
fileName,
|
||||
fileKey: `web-import/${source.userId}/${Date.now()}-${fileName}`,
|
||||
fileUrl: '',
|
||||
fileName: safeFileName,
|
||||
fileKey: storageKey,
|
||||
fileUrl,
|
||||
});
|
||||
const importSettings = await getImportSettingsByUser(source.userId);
|
||||
const userSettings = await getUserSettings(source.userId);
|
||||
const aiSettings = {
|
||||
aiProvider: importSettings?.aiProvider || 'manus',
|
||||
mistralApiKey: importSettings?.mistralApiKey || undefined,
|
||||
manusForgeApiUrl: importSettings?.manusForgeApiUrl || undefined,
|
||||
manusForgeApiKey: importSettings?.manusForgeApiKey || undefined,
|
||||
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, 'mistral-large-latest', undefined, aiSettings);
|
||||
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 || []) {
|
||||
@@ -375,7 +350,7 @@ async function startServer() {
|
||||
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: err.message });
|
||||
res.status(500).json({ error: "L’import web a échoué. Consultez les journaux serveur." });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -329,7 +329,6 @@ export async function invokeLLMWithUserSettings(
|
||||
}
|
||||
|
||||
const provider = userSettings?.aiProvider || "mistral";
|
||||
const isMistral = provider === "mistral";
|
||||
|
||||
const {
|
||||
messages,
|
||||
|
||||
Reference in New Issue
Block a user