From 67d8159787bb0939127424fd227718209fee7b5a Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 19 Mar 2026 07:11:00 +0100 Subject: [PATCH] =?UTF-8?q?Feat:=20s=C3=A9curisation=20formulaire=20white?= =?UTF-8?q?=20paper=20+=20stockage=20leads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Honeypot, timing check, rate limiting IP, validation serveur - Déduplication par email : enrichissement des champs vides si contact existant - Blueprint white-paper : onglet "Contacts intéressés" (champ structure contactDatabase) - Blueprint site.yml : ajout onglet "Données d'usage" pour vue globale des leads - Route externalisée dans site/config/routes/download-white-paper.php - isDownloadable côté client (prénom, nom, email valide, consentement) - Cursor : pas de hover sur boutons disabled - Buttons : hover désactivé si disabled Co-Authored-By: Claude Sonnet 4.6 --- site/blueprints/pages/white-paper.yml | 47 +++++ site/blueprints/site.yml | 182 +++++++++++--------- site/config/config.php | 21 +-- site/config/routes/download-white-paper.php | 99 +++++++++++ src/components/layout/Cursor.svelte | 2 +- src/i18n/index.js | 26 ++- src/styles/buttons.css | 7 +- src/views/WhitePaper.svelte | 40 ++++- 8 files changed, 312 insertions(+), 112 deletions(-) create mode 100644 site/config/routes/download-white-paper.php diff --git a/site/blueprints/pages/white-paper.yml b/site/blueprints/pages/white-paper.yml index c97619b..57d1c52 100644 --- a/site/blueprints/pages/white-paper.yml +++ b/site/blueprints/pages/white-paper.yml @@ -59,3 +59,50 @@ tabs: accept: application/pdf translate: false help: Fichier téléchargé après soumission du formulaire + leads: + label: Contacts intéressés + icon: users + sections: + contactDatabase: + type: fields + fields: + contactDatabase: + label: Visiteurs ayant téléchargé + type: structure + translate: false + columns: + firstName: + label: Prénom + width: 1/4 + lastName: + label: Nom + width: 1/4 + email: + label: Email + width: 1/4 + company: + label: Entreprise + width: 1/4 + role: + label: Fonction + downloadedAt: + label: Date + fields: + firstName: + type: text + label: Prénom + lastName: + type: text + label: Nom + email: + type: email + label: Email + company: + type: text + label: Entreprise + role: + type: text + label: Fonction + downloadedAt: + type: text + label: Date diff --git a/site/blueprints/site.yml b/site/blueprints/site.yml index f5a74b9..f4a3748 100644 --- a/site/blueprints/site.yml +++ b/site/blueprints/site.yml @@ -1,85 +1,113 @@ title: World Game -columns: - - width: 1/2 - sections: - navigation: - type: pages - label: Menu - help: Accédez aux pages et réordonnez-les - layout: list - size: small - sortable: true - create: false - status: listed - - - width: 1/2 - fields: - siteTitle: - label: Titre du site - type: text - translate: true - siteTagline: - label: Tagline - type: text - translate: true - siteDescription: - label: Description - type: textarea - translate: true - footerLogo: - label: Logo - type: files - layout: cards - max: 1 - help: Utilisé en pied de page (page À propos, Blog et Article). - - - width: 1/2 - fields: - contactEmail: - label: Email - type: email - width: 1/2 - translate: false - contactAddress: - label: Adresse - type: writer - buttons: false - nodes: false - width: 1/2 - placeholder: 33 rue Jean Dupont - socialLinks: - label: Liens réseaux sociaux - type: structure - translate: false - fields: - label: - label: Nom - required: true - type: text - width: 1/3 - placeholder: "Ex: LinkedIn, Instagram..." - url: - label: URL - required: true - type: url - width: 1/3 - picto: - label: Icône SVG - type: textarea - width: 1/3 - buttons: false +tabs: + mainTab: + label: Principal + columns: + - width: 1/2 + sections: + navigation: + type: pages + label: Menu + help: Accédez aux pages et réordonnez-les + layout: list size: small - help: Collez le code SVG de l'icône + sortable: true + create: false + status: listed - - width: 1/2 - sections: - contact: - type: fields + - width: 1/2 fields: - legalNotices: - label: Mentions légales (PDF) + siteTitle: + label: Titre du site + type: text + translate: true + siteTagline: + label: Tagline + type: text + translate: true + siteDescription: + label: Description + type: textarea + translate: true + footerLogo: + label: Logo type: files + layout: cards max: 1 - accept: application/pdf + help: Utilisé en pied de page (page À propos, Blog et Article). + + - width: 1/2 + fields: + contactEmail: + label: Email + type: email + width: 1/2 translate: false + contactAddress: + label: Adresse + type: writer + buttons: false + nodes: false + width: 1/2 + placeholder: 33 rue Jean Dupont + socialLinks: + label: Liens réseaux sociaux + type: structure + translate: false + fields: + label: + label: Nom + required: true + type: text + width: 1/3 + placeholder: "Ex: LinkedIn, Instagram..." + url: + label: URL + required: true + type: url + width: 1/3 + picto: + label: Icône SVG + type: textarea + width: 1/3 + buttons: false + size: small + help: Collez le code SVG de l'icône + + - width: 1/2 + sections: + contact: + type: fields + fields: + legalNotices: + label: Mentions légales (PDF) + type: files + max: 1 + accept: application/pdf + translate: false + dataTab: + label: Données d'usage + icon: chart + fields: + contactDatabase: + label: Ont téléchargé un livre blanc + type: structure + fields: + firstName: + label: Prénom + type: text + required: true + lastName: + label: Nom + type: text + required: true + company: + label: Société + type: text + role: + label: Fonction + type: text + email: + type: email + required: true diff --git a/site/config/config.php b/site/config/config.php index c88a313..40b0846 100644 --- a/site/config/config.php +++ b/site/config/config.php @@ -13,25 +13,6 @@ return [ 'thumbs' => require __DIR__ . '/thumbs.php', 'routes' => [ - [ - 'pattern' => ['(:any)/(:any)/download', 'en/(:any)/(:any)/download'], - 'method' => 'POST', - 'action' => function (string $parent, string $slug) { - $page = kirby()->page($parent . '/' . $slug); - if (!$page || $page->intendedTemplate()->name() !== 'white-paper') { - http_response_code(404); - header('Content-Type: application/json'); - echo json_encode(['error' => 'Not found']); - exit; - } - // TODO: store/email form data ($kirby->request()->body()) - header('Content-Type: application/json'); - echo json_encode([ - 'success' => true, - 'fileUrl' => $page->downloadFile()->toFile()?->url(), - ]); - exit; - } - ] + require(__DIR__ . '/routes/download-white-paper.php') ], ]; diff --git a/site/config/routes/download-white-paper.php b/site/config/routes/download-white-paper.php new file mode 100644 index 0000000..218e787 --- /dev/null +++ b/site/config/routes/download-white-paper.php @@ -0,0 +1,99 @@ + $message]); + exit; +} + +return [ + 'pattern' => ['(:any)/(:any)/download', 'en/(:any)/(:any)/download'], + 'method' => 'POST', + 'action' => function (string $parent, string $slug) { + + $page = page($parent . '/' . $slug); + if (!$page || $page->intendedTemplate()->name() !== 'white-paper') { + wpReject(404, 'Not found'); + } + + $body = kirby()->request()->body()->toArray(); + + // ── Honeypot ────────────────────────────────────────────── + if (!empty($body['_hp'])) { + wpReject(400, 'Bad request'); + } + + // ── Timing check (min 3 s) ──────────────────────────────── + $openedAt = isset($body['_t']) ? (int)$body['_t'] : 0; + if ($openedAt === 0 || (time() * 1000 - $openedAt) < 3000) { + wpReject(400, 'Too fast'); + } + + // ── Rate limiting (5 req / hour / IP) ───────────────────── + $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $cacheKey = 'wp-dl-' . md5($ip); + $cache = kirby()->cache('pages'); + $hits = (int)($cache->get($cacheKey) ?? 0); + if ($hits >= 5) { + wpReject(429, 'Too many requests'); + } + $cache->set($cacheKey, $hits + 1, 60); // TTL 60 min + + // ── Validation des champs requis ────────────────────────── + $firstName = trim($body['firstName'] ?? ''); + $lastName = trim($body['lastName'] ?? ''); + $email = trim($body['email'] ?? ''); + + if ($firstName === '' || $lastName === '' || $email === '') { + wpReject(422, 'Missing required fields'); + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + wpReject(422, 'Invalid email'); + } + + // ── Stocker le lead dans contactDatabase ────────────────── + $company = trim($body['company'] ?? ''); + $role = trim($body['role'] ?? ''); + $entries = $page->contactDatabase()->toStructure()->toArray(); + + $existingIndex = null; + foreach ($entries as $i => $entry) { + if (strtolower($entry['email'] ?? '') === strtolower($email)) { + $existingIndex = $i; + break; + } + } + + if ($existingIndex !== null) { + // Contact déjà présent — on enrichit les champs vides uniquement + if ($company !== '' && empty($entries[$existingIndex]['company'])) { + $entries[$existingIndex]['company'] = $company; + } + if ($role !== '' && empty($entries[$existingIndex]['role'])) { + $entries[$existingIndex]['role'] = $role; + } + } else { + $entries[] = [ + 'firstName' => $firstName, + 'lastName' => $lastName, + 'email' => $email, + 'company' => $company, + 'role' => $role, + 'downloadedAt' => date('d/m/Y H:i'), + ]; + } + + kirby()->impersonate('kirby', function () use ($page, $entries) { + $page->update(['contactDatabase' => \Kirby\Data\Data::encode($entries, 'yaml')]); + }); + + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'fileUrl' => $page->downloadFile()->toFile()?->url(), + ]); + exit; + } +]; diff --git a/src/components/layout/Cursor.svelte b/src/components/layout/Cursor.svelte index ac57847..f2d9e53 100644 --- a/src/components/layout/Cursor.svelte +++ b/src/components/layout/Cursor.svelte @@ -14,7 +14,7 @@ } const handleMouseOver = (e) => { - onTarget = !!e.target.closest('a, button, [role="button"], [tabindex]') + onTarget = !!e.target.closest('a, button:not([disabled]), [role="button"]:not([disabled]), [tabindex]') } const handleMouseOut = () => { diff --git a/src/i18n/index.js b/src/i18n/index.js index 1213085..4e6dd2d 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -50,16 +50,28 @@ const dict = { // White Papers white_paper_label: { fr: "LIVRE BLANC", en: "WHITE PAPER" }, read_wp: { fr: "Télécharger", en: "Download" }, - wp_form_intro: { fr: "Renseignez vos informations pour télécharger notre livre blanc.", en: "Fill in your information to download our white paper." }, + wp_form_intro: { + fr: "Renseignez vos informations pour télécharger notre livre blanc.", + en: "Fill in your information to download our white paper.", + }, wp_firstname: { fr: "Prénom*", en: "First name*" }, wp_lastname: { fr: "Nom*", en: "Last name*" }, - wp_company: { fr: "Société*", en: "Company*" }, - wp_role: { fr: "Fonction*", en: "Role*" }, + wp_company: { fr: "Société", en: "Company" }, + wp_role: { fr: "Fonction", en: "Role" }, wp_email: { fr: "E-mail*", en: "E-mail*" }, - wp_consent: { fr: "En cochant cette case, j'accepte d'être recontacté par la société World Game. Mes données ne seront ni vendues, ni partagées.", en: "By checking this box, I agree to be contacted by World Game. My data will not be sold or shared." }, - wp_download: { fr: "TÉLÉCHARGEMENT", en: "DOWNLOAD" }, - wp_success: { fr: "Votre demande a été enregistrée. Le téléchargement devrait démarrer.", en: "Your request has been registered. The download should start." }, - wp_error: { fr: "Une erreur est survenue, veuillez réessayer.", en: "An error occurred, please try again." }, + wp_consent: { + fr: "En cochant cette case, j'accepte d'être recontacté par la société World Game. Mes données ne seront ni vendues, ni partagées.", + en: "By checking this box, I agree to be contacted by World Game. My data will not be sold or shared.", + }, + wp_download: { fr: "TÉLÉCHARGER", en: "DOWNLOAD" }, + wp_success: { + fr: "Votre demande a été enregistrée. Le téléchargement devrait démarrer.", + en: "Your request has been registered. The download should start.", + }, + wp_error: { + fr: "Une erreur est survenue, veuillez réessayer.", + en: "An error occurred, please try again.", + }, // Menu menu: { fr: "MENU", en: "MENU" }, connect: { fr: "CONNECT", en: "CONNECT" }, diff --git a/src/styles/buttons.css b/src/styles/buttons.css index 5ab2b9b..c578ba5 100644 --- a/src/styles/buttons.css +++ b/src/styles/buttons.css @@ -2,6 +2,10 @@ button { border: none; } +button[disabled] { + cursor: none; +} + /* Button */ .button { width: 14vmax; @@ -26,7 +30,7 @@ button { outline: 2px solid #04fea0; } -.button:hover { +.button:not([disabled]):hover { background-color: initial; background-position: 0; outline: 2px solid #04fea0; @@ -64,6 +68,5 @@ button { /* Clickable elements */ .clickable { - cursor: pointer; user-select: none; } diff --git a/src/views/WhitePaper.svelte b/src/views/WhitePaper.svelte index 32b9c25..31f0871 100644 --- a/src/views/WhitePaper.svelte +++ b/src/views/WhitePaper.svelte @@ -12,7 +12,22 @@ let consent = $state(false) let submitting = $state(false) let status = $state(null) // null | 'success' | 'error' - let showForm = $state(false) + let showForm = $state(false) + let honeypot = $state('') + let formOpenedAt = $state(0) + + $effect(() => { + if (showForm && formOpenedAt === 0) formOpenedAt = Date.now() + }) + + let isEmailValid = $derived.by(() => { + const emailValidator = /^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$/gm + return emailValidator.test(email) + }) + + let isDownloadable = $derived.by(() => { + return firstName.length > 0 && lastName.length > 0 && email.length > 0 && isEmailValid && consent + }) async function handleSubmit(e) { e.preventDefault() @@ -24,7 +39,7 @@ const res = await fetch(`${prefix}/${data.uri}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ firstName, lastName, company, role, email }) + body: JSON.stringify({ firstName, lastName, company, role, email, _hp: honeypot, _t: formOpenedAt }) }) const result = await res.json() if (result.fileUrl) { @@ -77,10 +92,15 @@ - - + + + +