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:
parent
974067d986
commit
67d8159787
8 changed files with 312 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
],
|
||||
];
|
||||
|
|
|
|||
99
site/config/routes/download-white-paper.php
Normal file
99
site/config/routes/download-white-paper.php
Normal 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;
|
||||
}
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue