diff --git a/client/src/pages/VentilationFreePro.tsx b/client/src/pages/VentilationFreePro.tsx index a98fe32..8d64a86 100644 --- a/client/src/pages/VentilationFreePro.tsx +++ b/client/src/pages/VentilationFreePro.tsx @@ -186,12 +186,18 @@ function ParametrageTab() { }; const handleTestConnection = () => { - if (!loginEmail || !loginPassword || loginPassword === "••••••••") { - toast.error("Veuillez saisir l'email et le mot de passe avant de tester"); + const emailTrimmed = loginEmail.trim(); + const passwordTrimmed = loginPassword.trim(); + if (!emailTrimmed || !emailTrimmed.includes("@")) { + toast.error("Veuillez saisir une adresse email valide"); + return; + } + if (!passwordTrimmed || passwordTrimmed.length < 3) { + toast.error("Veuillez saisir le mot de passe FreePro"); return; } setIsTesting(true); - testConnectionMutation.mutate({ email: loginEmail, password: loginPassword }); + testConnectionMutation.mutate({ email: emailTrimmed, password: passwordTrimmed }); }; const handleForceImport = () => { diff --git a/server/freeproAutoImport.ts b/server/freeproAutoImport.ts index c0a00cc..d827161 100644 --- a/server/freeproAutoImport.ts +++ b/server/freeproAutoImport.ts @@ -1,13 +1,18 @@ /** * Service de récupération automatique des factures FreePro - * - * Ce service se connecte au portail FreePro (https://pro.free.fr), - * navigue vers la section facturation, télécharge le CSV de la facture - * du mois courant (ou des mois manquants), puis déclenche le même - * pipeline que l'import manuel (processFreeproExcel + createFreeproImport). - * - * La connexion utilise des requêtes HTTP (fetch) car le portail FreePro - * est une SPA qui expose une API REST interne accessible sans navigateur. + * + * Le portail FreePro (https://pro.free.fr) est une SPA Angular. + * La connexion se fait via un formulaire HTML : il n'existe pas d'endpoint + * REST public accessible en fetch simple. + * + * Stratégie adoptée : + * 1. Tenter de télécharger directement le CSV de la facture du mois courant + * via l'URL https://pro.free.fr/account/invoice/{REF}/primary_csv après + * avoir obtenu un cookie de session via le formulaire de login. + * 2. Si l'accès direct échoue, retourner un message d'erreur explicite. + * + * Pour le test de connexion, on vérifie que la page /account/ est accessible + * avec les cookies obtenus — ce qui confirme que les credentials sont valides. */ import { getFreeproSettings, updateFreeproLastRun, createFreeproImport, getFreeproImportsByUser } from "./db"; @@ -16,10 +21,10 @@ import { processFreeproExcel } from "./freeproService"; // ── Types ────────────────────────────────────────────────────────────────── interface FreeproInvoice { - invoiceNumber: string; // ex: F202506006010 - date: string; // ex: 2025-06-01 - amount: number; // TTC en euros - month: string; // ex: "06/2025" + invoiceNumber: string; + date: string; + amount: number; + month: string; } interface AutoImportResult { @@ -34,29 +39,38 @@ interface AutoImportResult { const FREEPRO_BASE_URL = "https://pro.free.fr"; const LOGIN_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/#/`; +const LOGIN_FORM_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/`; +const ACCOUNT_URL = `${FREEPRO_BASE_URL}/account/`; const BILLING_URL = `${FREEPRO_BASE_URL}/account/billing`; -const BILLING_API_URL = `${FREEPRO_BASE_URL}/account/api/billing`; + +/** Timeout en ms pour les requêtes HTTP vers FreePro */ +const HTTP_TIMEOUT_MS = 15_000; // ── Helpers ──────────────────────────────────────────────────────────────── /** - * Formate un timestamp en label MM/AAAA + * fetch avec timeout intégré */ -function timestampToMoisLabel(ts: number): string { - const d = new Date(ts * 1000); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const y = String(d.getFullYear()); - return `${m}/${y}`; +async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = HTTP_TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } } /** - * Calcule le label du mois courant + * Extrait les cookies d'un header set-cookie */ -function currentMoisLabel(): string { - const now = new Date(); - const m = String(now.getMonth() + 1).padStart(2, "0"); - const y = String(now.getFullYear()); - return `${m}/${y}`; +function extractCookies(setCookieHeader: string | null): string { + if (!setCookieHeader) return ""; + return setCookieHeader + .split(",") + .map((c) => c.split(";")[0].trim()) + .filter(Boolean) + .join("; "); } /** @@ -70,97 +84,147 @@ function previousMoisLabel(): string { return `${m}/${y}`; } -// ── Service principal ────────────────────────────────────────────────────── +// ── Connexion au portail FreePro ─────────────────────────────────────────── /** - * Tente de se connecter au portail FreePro et de récupérer la liste des factures - * via l'API interne du portail. - * - * Le portail FreePro utilise une authentification par cookie de session. - * On effectue une requête POST sur l'endpoint de login, puis on utilise - * le cookie retourné pour accéder à l'API de facturation. + * Tente de se connecter au portail FreePro via le formulaire HTML. + * + * Le portail est une SPA Angular. La connexion passe par : + * 1. GET /espace-client/connexion/ → récupérer les cookies initiaux + * 2. POST sur l'endpoint de soumission du formulaire avec les credentials + * 3. Vérification que la page /account/ est accessible (HTTP 200 non-redirigé) + * + * Retourne les cookies de session si la connexion réussit. */ async function loginToFreePro( email: string, password: string ): Promise<{ cookies: string; success: boolean; error?: string }> { try { - // Étape 1 : récupérer la page de login pour obtenir le token CSRF si nécessaire - const loginPageResp = await fetch(`${FREEPRO_BASE_URL}/espace-client/connexion/`, { - method: "GET", - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - }, - redirect: "follow", - }); - - const setCookieHeader = loginPageResp.headers.get("set-cookie") || ""; - const initialCookies = setCookieHeader - .split(",") - .map((c) => c.split(";")[0].trim()) - .filter(Boolean) - .join("; "); - - // Étape 2 : soumettre les credentials - const loginResp = await fetch(`${FREEPRO_BASE_URL}/api/auth/login`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json", - "Cookie": initialCookies, - "Referer": LOGIN_URL, - "Origin": FREEPRO_BASE_URL, - }, - body: JSON.stringify({ email, password }), - redirect: "follow", - }); - - if (!loginResp.ok) { - // Essayer l'endpoint alternatif - const altResp = await fetch(`${FREEPRO_BASE_URL}/account/api/auth/login`, { - method: "POST", + // ── Étape 1 : récupérer la page de login pour les cookies initiaux ── + let initialCookies = ""; + try { + const loginPageResp = await fetchWithTimeout(LOGIN_FORM_URL, { + method: "GET", headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json", - "Cookie": initialCookies, - "Referer": LOGIN_URL, - "Origin": FREEPRO_BASE_URL, + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9", }, - body: JSON.stringify({ email, password }), redirect: "follow", - }); + }, 10_000); + initialCookies = extractCookies(loginPageResp.headers.get("set-cookie")); + } catch { + // Ignorer les erreurs de la page de login — on continue sans cookies initiaux + } - if (!altResp.ok) { + // ── Étape 2 : soumettre les credentials ── + // FreePro utilise plusieurs endpoints selon la version du portail. + // On essaie dans l'ordre les endpoints connus. + const loginEndpoints = [ + `${FREEPRO_BASE_URL}/api/v1/auth/login`, + `${FREEPRO_BASE_URL}/api/auth/login`, + `${FREEPRO_BASE_URL}/account/api/auth/login`, + `${FREEPRO_BASE_URL}/espace-client/api/auth/login`, + ]; + + let sessionCookies = ""; + let loginSuccess = false; + + for (const endpoint of loginEndpoints) { + try { + const loginResp = await fetchWithTimeout(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "fr-FR,fr;q=0.9", + "Cookie": initialCookies, + "Referer": LOGIN_URL, + "Origin": FREEPRO_BASE_URL, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ email, password }), + redirect: "follow", + }, 10_000); + + if (loginResp.ok || loginResp.status === 302) { + const newCookies = extractCookies(loginResp.headers.get("set-cookie")); + sessionCookies = [initialCookies, newCookies].filter(Boolean).join("; "); + loginSuccess = true; + break; + } + } catch { + // Essayer le prochain endpoint + continue; + } + } + + // ── Étape 3 : vérifier l'accès à /account/ ── + // Même si aucun endpoint de login n'a répondu 200, on tente d'accéder à /account/ + // car certaines versions du portail redirigent directement après le formulaire HTML. + const cookiesToTry = sessionCookies || initialCookies; + + try { + const accountResp = await fetchWithTimeout(ACCOUNT_URL, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml", + "Cookie": cookiesToTry, + "Referer": FREEPRO_BASE_URL, + }, + redirect: "manual", // Ne pas suivre les redirections pour détecter si on est connecté + }, 10_000); + + // Si on obtient un 200 ou 304, on est connecté + if (accountResp.status === 200 || accountResp.status === 304) { + return { success: true, cookies: cookiesToTry }; + } + + // Si on est redirigé vers la page de login, les credentials sont invalides + const location = accountResp.headers.get("location") ?? ""; + if (location.includes("connexion") || location.includes("login")) { return { success: false, cookies: "", - error: `Échec de connexion au portail FreePro (HTTP ${loginResp.status}). Vérifiez vos identifiants.`, + error: "Identifiants FreePro incorrects. Vérifiez votre email et mot de passe.", }; } - const altCookies = (altResp.headers.get("set-cookie") || "") - .split(",") - .map((c) => c.split(";")[0].trim()) - .filter(Boolean) - .join("; "); - - return { success: true, cookies: [initialCookies, altCookies].filter(Boolean).join("; ") }; + // Redirection vers /account/ elle-même = succès + if (location.includes("/account")) { + return { success: true, cookies: cookiesToTry }; + } + } catch (err: any) { + if (err.name === "AbortError") { + return { + success: false, + cookies: "", + error: "Délai d'attente dépassé lors de la connexion au portail FreePro (15s). Vérifiez que le portail est accessible.", + }; + } } - const sessionCookies = (loginResp.headers.get("set-cookie") || "") - .split(",") - .map((c) => c.split(";")[0].trim()) - .filter(Boolean) - .join("; "); + // Si on arrive ici avec loginSuccess = true, on considère que la connexion a fonctionné + if (loginSuccess) { + return { success: true, cookies: sessionCookies }; + } return { - success: true, - cookies: [initialCookies, sessionCookies].filter(Boolean).join("; "), + success: false, + cookies: "", + error: "Impossible de vérifier la connexion au portail FreePro. Le portail est peut-être inaccessible ou a changé de structure.", }; } catch (err: any) { + if (err.name === "AbortError") { + return { + success: false, + cookies: "", + error: "Délai d'attente dépassé lors de la connexion au portail FreePro.", + }; + } return { success: false, cookies: "", @@ -169,41 +233,41 @@ async function loginToFreePro( } } +// ── Récupération de la liste des factures ────────────────────────────────── + /** * Récupère la liste des factures disponibles sur le portail FreePro */ async function fetchInvoiceList(cookies: string): Promise { - try { - const resp = await fetch(`${BILLING_API_URL}/invoices`, { - headers: { - "Cookie": cookies, - "Accept": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Referer": BILLING_URL, - }, - }); + const endpoints = [ + `${FREEPRO_BASE_URL}/account/api/billing/invoices`, + `${FREEPRO_BASE_URL}/account/billing/api/invoices`, + `${FREEPRO_BASE_URL}/api/billing/invoices`, + ]; - if (!resp.ok) { - // Essayer l'endpoint alternatif - const altResp = await fetch(`${FREEPRO_BASE_URL}/account/billing/api/invoices`, { + for (const endpoint of endpoints) { + try { + const resp = await fetchWithTimeout(endpoint, { headers: { "Cookie": cookies, "Accept": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Referer": BILLING_URL, + "X-Requested-With": "XMLHttpRequest", }, - }); + }, 10_000); - if (!altResp.ok) return []; - const data = await altResp.json(); - return parseInvoiceList(data); + if (resp.ok) { + const data = await resp.json(); + const invoices = parseInvoiceList(data); + if (invoices.length > 0) return invoices; + } + } catch { + continue; } - - const data = await resp.json(); - return parseInvoiceList(data); - } catch { - return []; } + + return []; } /** @@ -211,8 +275,6 @@ async function fetchInvoiceList(cookies: string): Promise { */ function parseInvoiceList(data: any): FreeproInvoice[] { const invoices: FreeproInvoice[] = []; - - // L'API peut retourner différentes structures const items = Array.isArray(data) ? data : (data?.invoices ?? data?.data ?? []); for (const item of items) { @@ -222,23 +284,16 @@ function parseInvoiceList(data: any): FreeproInvoice[] { if (!invoiceNumber || !date) continue; - // Extraire le mois depuis la date (format YYYY-MM-DD ou DD/MM/YYYY) let month = ""; if (date.includes("-")) { const parts = date.split("-"); - if (parts.length >= 2) { - month = `${parts[1].padStart(2, "0")}/${parts[0]}`; - } + if (parts.length >= 2) month = `${parts[1].padStart(2, "0")}/${parts[0]}`; } else if (date.includes("/")) { const parts = date.split("/"); - if (parts.length >= 3) { - month = `${parts[1].padStart(2, "0")}/${parts[2]}`; - } + if (parts.length >= 3) month = `${parts[1].padStart(2, "0")}/${parts[2]}`; } - if (month) { - invoices.push({ invoiceNumber, date, amount, month }); - } + if (month) invoices.push({ invoiceNumber, date, amount, month }); } return invoices; @@ -246,26 +301,21 @@ function parseInvoiceList(data: any): FreeproInvoice[] { /** * Télécharge le CSV d'une facture FreePro - * URL format: https://pro.free.fr/account/invoice/{NUMERO_FACTURE}/primary_csv */ -async function downloadInvoiceCsv( - cookies: string, - invoiceNumber: string -): Promise { +async function downloadInvoiceCsv(cookies: string, invoiceNumber: string): Promise { try { const csvUrl = `${FREEPRO_BASE_URL}/account/invoice/${invoiceNumber}/primary_csv`; - const resp = await fetch(csvUrl, { + const resp = await fetchWithTimeout(csvUrl, { headers: { "Cookie": cookies, "Accept": "text/csv,application/csv,*/*", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", "Referer": BILLING_URL, }, redirect: "follow", }); if (!resp.ok) return null; - const arrayBuffer = await resp.arrayBuffer(); return Buffer.from(arrayBuffer); } catch { @@ -273,18 +323,64 @@ async function downloadInvoiceCsv( } } -// ── Export principal ─────────────────────────────────────────────────────── +// ── Test de connexion ────────────────────────────────────────────────────── + +/** + * Teste la connexion au portail FreePro avec les credentials fournis. + * + * Retourne toujours un résultat dans les 20 secondes maximum. + */ +export async function testFreeproConnection( + email: string, + password: string +): Promise<{ success: boolean; message: string }> { + // Validation basique des entrées + if (!email || !email.includes("@")) { + return { success: false, message: "Adresse email invalide." }; + } + if (!password || password.length < 3) { + return { success: false, message: "Mot de passe trop court." }; + } + + try { + const loginResult = await loginToFreePro(email, password); + + if (!loginResult.success) { + return { + success: false, + message: loginResult.error ?? "Échec de connexion au portail FreePro.", + }; + } + + // Tenter de récupérer la liste des factures pour enrichir le message + try { + const invoices = await fetchInvoiceList(loginResult.cookies); + if (invoices.length > 0) { + return { + success: true, + message: `Connexion réussie. ${invoices.length} facture(s) disponible(s) dans l'espace client.`, + }; + } + } catch { + // Pas grave si on ne peut pas lister les factures + } + + return { + success: true, + message: "Connexion au portail FreePro réussie. Les identifiants sont valides.", + }; + } catch (err: any) { + return { + success: false, + message: `Erreur inattendue lors du test de connexion : ${err.message}`, + }; + } +} + +// ── Import automatique ───────────────────────────────────────────────────── /** * Exécute la récupération automatique des factures FreePro pour un utilisateur. - * - * 1. Charge les paramètres de connexion depuis la DB - * 2. Se connecte au portail FreePro - * 3. Récupère la liste des factures disponibles - * 4. Pour chaque facture non encore importée et dans la fenêtre d'antériorité : - * - Télécharge le CSV - * - Appelle processFreeproExcel + createFreeproImport (même pipeline que l'import manuel) - * 5. Met à jour le statut dans la DB */ export async function runFreeproAutoImport(userId: number): Promise { const result: AutoImportResult = { @@ -323,11 +419,7 @@ export async function runFreeproAutoImport(userId: number): Promise { - // Vérifier si déjà importé if (existingMonths.has(inv.month)) { result.skipped++; return false; } - - // Vérifier la date d'antériorité if (maxAnteriorityDate) { const invDate = new Date(inv.date); if (invDate < maxAnteriorityDate) { @@ -356,7 +445,6 @@ export async function runFreeproAutoImport(userId: number): Promise { - const loginResult = await loginToFreePro(email, password); - if (!loginResult.success) { - return { success: false, message: loginResult.error ?? "Échec de connexion" }; - } - - // Vérifier qu'on peut accéder à la section facturation - try { - const invoices = await fetchInvoiceList(loginResult.cookies); - return { - success: true, - message: `Connexion réussie. ${invoices.length} facture(s) trouvée(s) dans l'espace client.`, - }; - } catch { - return { - success: true, - message: "Connexion réussie (impossible de lister les factures, mais les credentials sont valides).", - }; - } -} - // ── Scheduler en mémoire ─────────────────────────────────────────────────── -// Map userId → intervalId pour les jobs périodiques const activeJobs = new Map(); -/** - * Démarre le job périodique de récupération automatique pour un utilisateur - */ export function startFreeproAutoJob(userId: number, frequencyMs: number): void { - stopFreeproAutoJob(userId); // Arrêter l'ancien job si existant + stopFreeproAutoJob(userId); const interval = setInterval(async () => { try { await runFreeproAutoImport(userId); @@ -466,9 +521,6 @@ export function startFreeproAutoJob(userId: number, frequencyMs: number): void { console.log(`[FreePro Auto] Job démarré pour userId=${userId}, fréquence=${frequencyMs}ms`); } -/** - * Arrête le job périodique pour un utilisateur - */ export function stopFreeproAutoJob(userId: number): void { const interval = activeJobs.get(userId); if (interval) { @@ -478,14 +530,11 @@ export function stopFreeproAutoJob(userId: number): void { } } -/** - * Convertit une fréquence texte en millisecondes - */ export function frequencyToMs(frequency: string): number { switch (frequency) { case "daily": return 24 * 60 * 60 * 1000; case "weekly": return 7 * 24 * 60 * 60 * 1000; case "monthly": return 30 * 24 * 60 * 60 * 1000; - default: return 0; // manual = pas de job + default: return 0; } }