Feat: sécurisation formulaire white paper + stockage leads

- 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 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-19 07:11:00 +01:00
parent 974067d986
commit 67d8159787
8 changed files with 312 additions and 112 deletions

View file

@ -59,3 +59,50 @@ tabs:
accept: application/pdf accept: application/pdf
translate: false translate: false
help: Fichier téléchargé après soumission du formulaire 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

View file

@ -1,85 +1,113 @@
title: World Game title: World Game
columns: tabs:
- width: 1/2 mainTab:
sections: label: Principal
navigation: columns:
type: pages - width: 1/2
label: Menu sections:
help: Accédez aux pages et réordonnez-les navigation:
layout: list type: pages
size: small label: Menu
sortable: true help: Accédez aux pages et réordonnez-les
create: false layout: list
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
size: small size: small
help: Collez le code SVG de l'icône sortable: true
create: false
status: listed
- width: 1/2 - width: 1/2
sections:
contact:
type: fields
fields: fields:
legalNotices: siteTitle:
label: Mentions légales (PDF) 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 type: files
layout: cards
max: 1 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 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

View file

@ -13,25 +13,6 @@ return [
'thumbs' => require __DIR__ . '/thumbs.php', 'thumbs' => require __DIR__ . '/thumbs.php',
'routes' => [ 'routes' => [
[ require(__DIR__ . '/routes/download-white-paper.php')
'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;
}
]
], ],
]; ];

View file

@ -0,0 +1,99 @@
<?php
function wpReject(int $code, string $message): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(['error' => $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;
}
];

View file

@ -14,7 +14,7 @@
} }
const handleMouseOver = (e) => { 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 = () => { const handleMouseOut = () => {

View file

@ -50,16 +50,28 @@ const dict = {
// White Papers // White Papers
white_paper_label: { fr: "LIVRE BLANC", en: "WHITE PAPER" }, white_paper_label: { fr: "LIVRE BLANC", en: "WHITE PAPER" },
read_wp: { fr: "Télécharger", en: "Download" }, 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_firstname: { fr: "Prénom*", en: "First name*" },
wp_lastname: { fr: "Nom*", en: "Last name*" }, wp_lastname: { fr: "Nom*", en: "Last name*" },
wp_company: { fr: "Société*", en: "Company*" }, wp_company: { fr: "Société", en: "Company" },
wp_role: { fr: "Fonction*", en: "Role*" }, wp_role: { fr: "Fonction", en: "Role" },
wp_email: { fr: "E-mail*", en: "E-mail*" }, 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_consent: {
wp_download: { fr: "TÉLÉCHARGEMENT", en: "DOWNLOAD" }, 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.",
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." }, en: "By checking this box, I agree to be contacted by World Game. My data will not be sold or shared.",
wp_error: { fr: "Une erreur est survenue, veuillez réessayer.", en: "An error occurred, please try again." }, },
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
menu: { fr: "MENU", en: "MENU" }, menu: { fr: "MENU", en: "MENU" },
connect: { fr: "CONNECT", en: "CONNECT" }, connect: { fr: "CONNECT", en: "CONNECT" },

View file

@ -2,6 +2,10 @@ button {
border: none; border: none;
} }
button[disabled] {
cursor: none;
}
/* Button */ /* Button */
.button { .button {
width: 14vmax; width: 14vmax;
@ -26,7 +30,7 @@ button {
outline: 2px solid #04fea0; outline: 2px solid #04fea0;
} }
.button:hover { .button:not([disabled]):hover {
background-color: initial; background-color: initial;
background-position: 0; background-position: 0;
outline: 2px solid #04fea0; outline: 2px solid #04fea0;
@ -64,6 +68,5 @@ button {
/* Clickable elements */ /* Clickable elements */
.clickable { .clickable {
cursor: pointer;
user-select: none; user-select: none;
} }

View file

@ -12,7 +12,22 @@
let consent = $state(false) let consent = $state(false)
let submitting = $state(false) let submitting = $state(false)
let status = $state(null) // null | 'success' | 'error' 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) { async function handleSubmit(e) {
e.preventDefault() e.preventDefault()
@ -24,7 +39,7 @@
const res = await fetch(`${prefix}/${data.uri}/download`, { const res = await fetch(`${prefix}/${data.uri}/download`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const result = await res.json()
if (result.fileUrl) { if (result.fileUrl) {
@ -77,10 +92,15 @@
<input class="input" type="text" placeholder={t('wp_firstname')} bind:value={firstName} required /> <input class="input" type="text" placeholder={t('wp_firstname')} bind:value={firstName} required />
<input class="input" type="text" placeholder={t('wp_lastname')} bind:value={lastName} required /> <input class="input" type="text" placeholder={t('wp_lastname')} bind:value={lastName} required />
</div> </div>
<input class="input" type="text" placeholder={t('wp_company')} bind:value={company} required /> <input class="input" type="text" placeholder={t('wp_company')} bind:value={company} />
<input class="input" type="text" placeholder={t('wp_role')} bind:value={role} required /> <input class="input" type="text" placeholder={t('wp_role')} bind:value={role} />
<input class="input" type="email" placeholder={t('wp_email')} bind:value={email} required /> <input class="input" type="email" placeholder={t('wp_email')} bind:value={email} required />
<div class="hp" aria-hidden="true">
<label for="website">Website</label>
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" bind:value={honeypot} />
</div>
<label class="consent"> <label class="consent">
<input type="checkbox" bind:checked={consent} required /> <input type="checkbox" bind:checked={consent} required />
<span>{t('wp_consent')}</span> <span>{t('wp_consent')}</span>
@ -92,7 +112,7 @@
<p class="status status--error">{t('wp_error')}</p> <p class="status status--error">{t('wp_error')}</p>
{/if} {/if}
<button type="submit" class="submit button" disabled={submitting || !consent}> <button type="submit" class="submit button" disabled={submitting || !isDownloadable}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 16L7 11H10V4H14V11H17L12 16Z" fill="currentColor"/> <path d="M12 16L7 11H10V4H14V11H17L12 16Z" fill="currentColor"/>
<path d="M5 20H19V18H5V20Z" fill="currentColor"/> <path d="M5 20H19V18H5V20Z" fill="currentColor"/>
@ -228,6 +248,16 @@
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
.hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.consent { .consent {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;