Aller au contenu principal

Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus

Construisez un widget de feedback lecteur sécurisé et SSR-safe pour votre blog Docusaurus — sélection de texte, 4 types de feedback, API PHP avec nonce HMAC, rate limiting, déduplication, dashboard admin et notifications par email.

· 14 minutes de lecture
Voir la source
Christophe
Amoureux de Markdown, WSL et Docker ~ Curieux insatiable.
Claude Code
AI Assistant
Cet article existe aussi en anglais — lire la version originale.

Traduction automatique

Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute, consultez l'article de référence en anglais — c'est lui qui fait foi.
Cet article fait partie de la série Créer des composants Docusaurus :

Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus

⚡
TL;DR

Un lecteur repère un problème dans votre article — une faute de frappe, une erreur factuelle, une commande obsolète. Il n'a aucun moyen de vous le dire. Il passe à autre chose. Vous n'en saurez jamais rien. Cet article construit de A à Z un widget de feedback à sens unique : le lecteur sélectionne un passage, un popover apparaît avec quatre types de problème (faute de frappe, information incorrecte, contenu obsolète, suggestion), il ajoute éventuellement des détails, et le signalement atterrit dans un fichier JSON sur votre serveur avec une notification par email. L'API PHP est durcie avec des nonces HMAC, un honeypot, du rate limiting et de la déduplication. Le composant React est SSR-safe, utilise une machine à états propre et limite les envois via localStorage. Un dashboard admin associé, sur /typo-dashboard, vous permet de relire tous les signalements par type. Pas de base de données, pas de service tiers, aucune dépendance npm au-delà de ce que Docusaurus embarque déjà.

Votre blog est votre vitrine publique. Une faute de frappe qui traîne, une commande qui ne fonctionne plus, une API qui a changé — chaque lecteur qui tombe sur ce paragraphe vous coûte en crédibilité. Le problème, c'est que la plupart des lecteurs ne prendront pas la peine d'ouvrir une issue GitHub ou d'envoyer un email. Mais si vous rendez la chose vraiment triviale — un clic, là où ils se trouvent — un nombre surprenant le fera.

Cet article construit ce mécanisme de bout en bout.

Démonstration​

Le widget est actif sur cette page même. Sélectionnez quelques mots de ce paragraphe avec votre souris (ou votre doigt sur un téléphone) : un popover apparaît juste sous la sélection, avec quatre types de feedback.

https://www.avonture.be/blog/docusaurus-typo-report-component

Le popover de feedback, avec ses quatre types, sous une sélection de texte

Le lecteur en choisit un, puis un petit formulaire lui permet d'ajouter un commentaire facultatif avant l'envoi. Le formulaire le dit clairement : c'est un signal à sens unique — le lecteur ne laisse pas d'email, donc personne n'attend de réponse.

https://www.avonture.be/blog/docusaurus-typo-report-component

Le formulaire de détails, affiché une fois le type de feedback choisi

De votre côté, chaque signalement atterrit dans un fichier JSON sur le serveur, vous recevez un email, et une page protégée par token liste tout, du plus récent au plus ancien, avec des compteurs par type :

https://www.avonture.be/typo-dashboard

Le dashboard admin listant les signalements reçus

Comment ça marche​

La fonctionnalité se compose de deux moitiés indépendantes qui reprennent le schéma utilisé par le composant Reaction (votes like / dislike) :

Reader selects text
└─ mouseup / touchend fires on <article>
└─ popover shows 4 feedback-type buttons (phase: selecting)
└─ reader picks a type → optional details form (phase: confirming)
└─ POST api/typo.php
└─ validates nonce, honeypot, rate limits, dedup
└─ writes typo-data.json
└─ emails admin (6h cooldown)
  • Pas de base de données — les signalements sont ajoutés à un fichier JSON, sous verrou de fichier.
  • Pas de service tiers — un simple script PHP posé à côté de vos fichiers statiques.
  • Défense en profondeur — neuf couches indépendantes bloquent les bots, les floods et les doublons (détaillées à la fin, pour les curieux).
  • SSR-safe — le composant ne rend rien tant que le lecteur n'a pas sélectionné de texte.

Partie 1 — L'API PHP​

Pourquoi PHP ?​

Docusaurus produit un site statique. Il n'a aucun runtime côté serveur. Si vous êtes sur un hébergement mutualisé (OVH, Infomaniak, o2switch…), un petit PHP script posé à côté de vos fichiers statiques est le backend le moins contraignant possible — pas de process Node à gérer, pas de container Docker, pas de démarrage à froid serverless.

Structure des fichiers sur le serveur​

File structure

public_html/
▼api/
.env ← secrets (never committed)
.env.example ← template committed to git
.htaccess ← protects JSON files
typo.php ← the API
typo-data.json ← reports (writable by web server)
typo-ratelimit.json ← counters
typo-notifications.json ← email throttle

Le script​

L'API répond à trois requêtes : GET distribue un nonce (et, avec ?admin=TOKEN, la liste complète des signalements), POST enregistre un signalement, OPTIONS répond au preflight CORS. Chaque POST traverse les neuf couches décrites dans la section sécurité, à la fin de cet article, avant que quoi que ce soit ne soit écrit.

<?php
declare(strict_types=1);
$_envVars = file_exists(__DIR__ . '/.env')
? (parse_ini_file(__DIR__ . '/.env') ?: [])
: [];
define('ADMIN_EMAIL', $_envVars['ADMIN_EMAIL'] ?? '');
define('ADMIN_TOKEN', $_envVars['ADMIN_TOKEN'] ?? '');
define('IP_HASH_SALT', $_envVars['IP_HASH_SALT'] ?? '');
define('NONCE_SECRET', $_envVars['NONCE_SECRET'] ?? '');
define('SITE_URL', 'https://www.yourdomain.com');
define('NONCE_COOLDOWN_SECONDS', 900);
define('NOTIFY_COOLDOWN_SECONDS', 21600);
define('GLOBAL_WINDOW', 60);
define('GLOBAL_MAX', 20);
define('IP_HOURLY_MAX', 10);
define('IP_ARTICLE_MAX', 3);
// ── CORS ──────────────────────────────────────────────────────────────────────
$allowedOrigins = [SITE_URL, 'http://localhost:3000'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && !in_array($origin, $allowedOrigins, true)) {
http_response_code(403); exit;
}
header('Content-Type: application/json; charset=utf-8');
if ($origin !== '') {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Vary: Origin');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
// ── Shared helpers ────────────────────────────────────────────────────────────
function loadData(string $file): array {
if (!file_exists($file)) return [];
return json_decode(file_get_contents($file), true) ?: [];
}
function saveData(string $file, array $data): void {
$fp = fopen($file, 'c+');
if (!$fp) return;
if (flock($fp, LOCK_EX)) {
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
flock($fp, LOCK_UN);
}
fclose($fp);
}
function sanitizeSlug(string $raw): string {
return substr(preg_replace('/[^a-z0-9\-\/]/', '', strtolower(trim($raw))), 0, 200);
}
function jsonError(int $code, string $message): never {
http_response_code($code);
echo json_encode(['error' => $message]);
exit;
}
// ── Nonce ─────────────────────────────────────────────────────────────────────
function makeNonce(int $window): string {
return hash_hmac('sha256', $window . '|typo-report', NONCE_SECRET);
}
function validateNonce(string $candidate): bool {
$now = (int) floor(time() / NONCE_COOLDOWN_SECONDS);
return hash_equals(makeNonce($now), $candidate)
|| hash_equals(makeNonce($now - 1), $candidate);
}
// ── Input validation ──────────────────────────────────────────────────────────
function validateAndSanitize(array $body): array {
if (($body['website'] ?? '') !== '') return []; // honeypot
$slug = sanitizeSlug($body['slug'] ?? '');
if ($slug === '') jsonError(400, 'Missing slug');
$text = trim($body['text'] ?? '');
if (mb_strlen($text) < 3 || mb_strlen($text) > 150
|| !preg_match('/^\P{C}+$/u', $text)) jsonError(400, 'Invalid text');
$allowedTypes = ['typo', 'incorrect', 'outdated', 'suggestion'];
$type = trim($body['type'] ?? '');
if (!in_array($type, $allowedTypes, true)) jsonError(400, 'Invalid type');
$comment = trim($body['comment'] ?? '');
if ($comment !== '' && (mb_strlen($comment) > 300
|| !preg_match('/^\P{C}+$/u', $comment))) jsonError(400, 'Invalid comment');
$context = mb_substr(trim($body['context'] ?? ''), 0, 300);
return compact('slug', 'text', 'type', 'comment', 'context');
}
// ── Rate limiting ─────────────────────────────────────────────────────────────
function checkRateLimits(string $slug): void {
$file = __DIR__ . '/typo-ratelimit.json';
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ipKey = hash('sha256', IP_HASH_SALT . $ip);
$now = time();
$fp = fopen($file, 'c+');
if (!$fp || !flock($fp, LOCK_EX)) { if ($fp) fclose($fp); return; }
$raw = stream_get_contents($fp);
$data = $raw !== '' ? (json_decode($raw, true) ?: []) : [];
$g = $data['__global__'] ?? ['window_start' => $now, 'count' => 0];
if ($now - $g['window_start'] > GLOBAL_WINDOW) $g = ['window_start' => $now, 'count' => 0];
if (++$g['count'] > GLOBAL_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }
$data['__global__'] = $g;
$ipData = $data[$ipKey] ?? ['hourly' => [], 'articles' => []];
$ipData['hourly'] = array_values(array_filter($ipData['hourly'], fn($ts) => $ts > $now - 3600));
if (count($ipData['hourly']) >= IP_HOURLY_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }
$artKey = $ipKey . '|' . $slug;
$artData = array_values(array_filter($data[$artKey] ?? [], fn($ts) => $ts > $now - 86400));
if (count($artData) >= IP_ARTICLE_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }
$ipData['hourly'][] = $now;
$artData[] = $now;
$data[$ipKey] = $ipData;
$data[$artKey] = $artData;
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
flock($fp, LOCK_UN); fclose($fp);
}
// ── Dedup + store ─────────────────────────────────────────────────────────────
function isDuplicate(string $slug, string $text): bool {
$hash = hash('sha256', $slug . '|' . mb_strtolower($text));
foreach (loadData(__DIR__ . '/typo-data.json')[$slug] ?? [] as $r) {
if (($r['text_hash'] ?? '') === $hash) return true;
}
return false;
}
function storeReport(array $fields): void {
['slug' => $slug, 'text' => $text, 'type' => $type,
'comment' => $comment, 'context' => $context] = $fields;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$fp = fopen(__DIR__ . '/typo-data.json', 'c+');
if (!$fp) return;
if (flock($fp, LOCK_EX)) {
$raw = stream_get_contents($fp);
$data = $raw !== '' ? (json_decode($raw, true) ?: []) : [];
$data[$slug][] = [
'id' => bin2hex(random_bytes(6)),
'type' => $type,
'text' => $text,
'text_hash' => hash('sha256', $slug . '|' . mb_strtolower($text)),
'context' => $context,
'comment' => $comment,
'ts' => time(),
'ip_hash' => hash('sha256', IP_HASH_SALT . $ip),
];
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
flock($fp, LOCK_UN);
}
fclose($fp);
}
function maybeNotifyTypo(string $slug, string $text, string $type): void {
$file = __DIR__ . '/typo-notifications.json';
$throttle = loadData($file);
if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) return;
$typeLabels = [
'typo' => '🔤 Typo',
'incorrect' => '❌ Incorrect info',
'outdated' => '⏰ Outdated content',
'suggestion' => '💡 Suggestion',
];
$typeLabel = $typeLabels[$type] ?? $type;
$articleUrl = SITE_URL . '/' . $slug;
$subject = "[Blog] Feedback ($type) on: $slug";
$body = implode("\n", [
"A reader flagged an issue.",
"",
"Article : $articleUrl",
"Type : $typeLabel",
"Text : $text",
"",
"Review all reports: GET " . SITE_URL . "/api/typo.php?admin=TOKEN",
]);
$headers = "From: noreply@yourdomain.com\r\nContent-Type: text/plain; charset=utf-8";
if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) {
$throttle[$slug] = time();
saveData($file, $throttle);
}
}
// ── Router ────────────────────────────────────────────────────────────────────
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
if (array_key_exists('nonce', $_GET)) {
echo json_encode(['nonce' => makeNonce((int) floor(time() / NONCE_COOLDOWN_SECONDS))]);
exit;
}
if (array_key_exists('admin', $_GET)) {
if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) jsonError(403, 'Forbidden');
echo json_encode(loadData(__DIR__ . '/typo-data.json'));
exit;
}
jsonError(400, 'Missing parameter');
}
if ($method !== 'POST') jsonError(405, 'Method not allowed');
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$nonce = $body['nonce'] ?? '';
if ($nonce === '' || !validateNonce($nonce)) jsonError(403, 'Invalid nonce');
$fields = validateAndSanitize($body);
if ($fields === []) { echo json_encode(['ok' => true]); exit; }
['slug' => $slug, 'text' => $text] = $fields;
checkRateLimits($slug);
if (isDuplicate($slug, $text)) { echo json_encode(['ok' => true]); exit; }
storeReport($fields);
maybeNotifyTypo($slug, $text, $fields['type']);
echo json_encode(['ok' => true]);

Ce script PHP implémente une API de signalement de typos pour un blog. Il charge la configuration depuis un fichier .env, valide les requêtes CORS, puis expose deux endpoints : l'un pour générer des jetons de sécurité (nonce), l'autre pour recevoir et stocker les signalements de typos avec protection contre les abus via limitation de débit et déduplications.

Protéger les fichiers JSON​

Sans la règle .htaccess livrée dans api/.htaccess, n'importe qui peut faire GET https://yourdomain.com/api/typo-data.json et lire tous les signalements.

Les secrets​

Le fichier api/.env contient deux secrets. Générez-les avec :

>user@machine: ~/yourproject

Partie 2 — Le composant React​

La sécurité SSR d'abord​

Docusaurus rend les pages côté serveur (Node.js) avant de les hydrater dans le navigateur. Tout code qui touche à window, document ou localStorage fera planter la passe SSR s'il s'exécute au niveau du module ou dans le corps du composant.

Les règles :

  1. Tout accès au DOM se fait dans un useEffect — il ne s'exécute que dans le navigateur.
  2. L'état est initialisé à "idle", pas à une valeur lue depuis le navigateur.
  3. Le composant retourne null à l'état idle — pas de DOM, pas de mismatch SSR.

Machine à états​

Six phases, linéaires avec une seule bifurcation :

idle
└─ user selects text → selecting
└─ user picks a feedback type → confirming
└─ form submitted → submitting
├─ success → done
└─ error → error

done et error affichent un badge que l'on peut fermer et qui remet l'état à idle. selecting et confirming repassent à idle si l'utilisateur clique en dehors du popover. confirming propose un lien « changer » qui ramène à selecting.

Les helpers localStorage​

Avant que le popover de sélection du type n'apparaisse, deux gardes côté client s'exécutent :

  1. Rate limit — maximum 5 envois par navigateur et par heure. Les timestamps sont stockés dans un tableau JSON dans localStorage sous la clé "typo_reports". Les entrées de plus de 24 heures sont purgées à chaque écriture.

  2. Déduplication — un hash FNV-1a de slug + "|" + lowercase(text) est stocké à côté du timestamp. Si le même texte a déjà été signalé depuis ce navigateur (quel que soit le type), le composant saute directement à done.

FNV-1a est utilisé plutôt que crypto.subtle.digest parce qu'il est synchrone, et le risque de collision est acceptable dans ce cas d'usage.

function fnv1a(str) {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return hash.toString(16);
}

Les types de feedback​

Quatre types couvrent la grande majorité des problèmes qu'un lecteur peut remarquer :

const FEEDBACK_TYPES = [
{ id: "typo", icon: "🔤", label: "Typo" },
{ id: "incorrect", icon: "❌", label: "Incorrect" },
{ id: "outdated", icon: "⏰", label: "Outdated" },
{ id: "suggestion", icon: "💡", label: "Suggestion" },
];

Ils sont affichés dans une grille 2×2. L'API PHP valide le champ type contre cette même whitelist — toute autre valeur renvoie un 400.

Capturer le contexte​

Quand l'utilisateur sélectionne du texte, le composant enregistre ±100 caractères de texte environnant depuis article.innerText. Ce contexte est envoyé à l'API pour que vous puissiez localiser la phrase exacte sans ouvrir l'article :

const fullText = article.innerText || "";
const idx = fullText.indexOf(text);
contextRef.current = idx !== -1
? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100)
: "";

Positionnement du tooltip​

Le popover apparaît juste sous la sélection. window.scrollY + rect.bottom + 8 le place 8px sous le bord inférieur du texte sélectionné. Pour éviter que le formulaire de 280px de large ne déborde du bord droit du viewport :

const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296);

Le composant​

import {
useState,
useEffect,
useRef,
useCallback,
type FormEvent,
type JSX,
} from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./styles.module.css";
const STORAGE_KEY = "typo_reports"; // [{ts, hash}]
const MAX_PER_HOUR = 5;
const FEEDBACK_TYPES = [
{ id: "typo", icon: "🔤", label: "Typo" },
{ id: "incorrect", icon: "❌", label: "Incorrect" },
{ id: "outdated", icon: "⏰", label: "Outdated" },
{ id: "suggestion", icon: "💡", label: "Suggestion" },
];
interface StoredReport {
ts: number;
hash: string;
}
// FNV-1a 32-bit hash (no crypto needed for client-side dedup)
function fnv1a(str: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return hash.toString(16);
}
function getStored(): StoredReport[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
} catch {
return [];
}
}
function isLocalRateLimited() {
const now = Date.now();
return getStored().filter((r) => r.ts > now - 3_600_000).length >= MAX_PER_HOUR;
}
function isLocalDuplicate(slug: string, text: string) {
const hash = fnv1a(slug + "|" + text.toLowerCase());
return getStored().some((r) => r.hash === hash);
}
function recordLocalSubmission(slug: string, text: string) {
const now = Date.now();
const hash = fnv1a(slug + "|" + text.toLowerCase());
const pruned = getStored().filter((r) => r.ts > now - 86_400_000);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...pruned, { ts: now, hash }]));
} catch {}
}
interface Props {
metadata?: {
permalink?: string;
};
}
type Phase = "idle" | "selecting" | "confirming" | "submitting" | "done" | "error";
export default function TypoReport({ metadata }: Props): JSX.Element | null {
const { siteConfig } = useDocusaurusContext();
const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? "";
const apiUrl = `${siteConfig.url}/api/typo.php`;
// State machine: idle → selecting → confirming → submitting → done | error
const [phase, setPhase] = useState<Phase>("idle");
const [selectedText, setSelectedText] = useState("");
const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
const [feedbackType, setFeedbackType] = useState("");
const [comment, setComment] = useState("");
const nonceRef = useRef<string | null>(null);
const articleRef = useRef<HTMLElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const contextRef = useRef(""); // ±100 chars around selection
// Fetch nonce + wire up DOM listeners on mount.
useEffect(() => {
if (!slug) return;
fetch(`${apiUrl}?nonce`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.nonce) nonceRef.current = data.nonce;
})
.catch(() => {});
const article =
document.querySelector<HTMLElement>("article") ||
document.querySelector<HTMLElement>(".theme-doc-markdown");
if (!article) return;
articleRef.current = article;
// Arrow-assigned, not a `function` declaration: that lets TS carry the
// `if (!article) return` narrowing above into this closure, so `article` is
// non-null on lines below without a `!` assertion.
const handleSelection = () => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
const text = sel.toString().trim();
if (text.length < 3) return;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!article.contains(range.commonAncestorContainer)) return;
const fullText = article.innerText || "";
const idx = fullText.indexOf(text);
contextRef.current =
idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : "";
const top = window.scrollY + rect.bottom + 8;
const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296);
setSelectedText(text);
setTooltipPos({ top, left });
setPhase("selecting");
};
function handleMouseDown(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setPhase("idle");
}
}
article.addEventListener("mouseup", handleSelection);
article.addEventListener("touchend", handleSelection);
document.addEventListener("mousedown", handleMouseDown);
return () => {
article.removeEventListener("mouseup", handleSelection);
article.removeEventListener("touchend", handleSelection);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [slug, apiUrl]);
const handleTypeSelect = useCallback(
(type: string) => {
if (isLocalRateLimited()) {
setPhase("error");
return;
}
if (isLocalDuplicate(slug, selectedText)) {
setPhase("done");
return;
}
setFeedbackType(type);
setComment("");
setPhase("confirming");
},
[slug, selectedText],
);
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPhase("submitting");
let nonce = nonceRef.current;
if (!nonce) {
try {
const r = await fetch(`${apiUrl}?nonce`);
const d = r.ok ? await r.json() : null;
nonce = d?.nonce ?? null;
nonceRef.current = nonce;
} catch {}
}
const honeypot =
(e.currentTarget.elements.namedItem("website") as HTMLInputElement | null)
?.value ?? "";
try {
const res = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slug,
text: selectedText,
type: feedbackType,
comment,
context: contextRef.current,
website: honeypot,
nonce: nonce ?? "",
}),
});
if (!res.ok) {
setPhase("error");
return;
}
recordLocalSubmission(slug, selectedText);
setPhase("done");
} catch {
setPhase("error");
}
},
[slug, selectedText, feedbackType, comment, apiUrl],
);
const handleBack = useCallback(() => setPhase("selecting"), []);
const handleCancel = useCallback(() => setPhase("idle"), []);
const handleDismiss = useCallback(() => setPhase("idle"), []);
// SSR-safe: render nothing when idle.
if (phase === "idle") return null;
const typeInfo = FEEDBACK_TYPES.find((t) => t.id === feedbackType);
return (
<div
ref={wrapperRef}
className={styles.wrapper}
style={{ top: tooltipPos.top, left: tooltipPos.left }}
>
{phase === "selecting" && (
<div className={styles.typeCard}>
<p className={styles.typePrompt}>What kind of issue?</p>
<div className={styles.typeGrid}>
{FEEDBACK_TYPES.map(({ id, icon, label }) => (
<button
key={id}
className={styles.typeBtn}
onClick={() => handleTypeSelect(id)}
>
<span className={styles.typeIcon}>{icon}</span>
<span>{label}</span>
</button>
))}
</div>
<button className={styles.cancelSmall} onClick={handleCancel}>
Cancel
</button>
</div>
)}
{phase === "confirming" && (
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.selectedPreview}>
&quot;
{selectedText.length > 80 ? selectedText.slice(0, 80) + "…" : selectedText}
&quot;
</div>
{typeInfo && (
<div className={styles.typeBadge}>
{typeInfo.icon} {typeInfo.label}
<button type="button" className={styles.changeTypeBtn} onClick={handleBack}>
change
</button>
</div>
)}
<textarea
className={styles.commentInput}
placeholder="Optional details (max 300 chars)"
maxLength={300}
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{/* Honeypot — bots fill this, humans don't see it */}
<input
name="website"
className={styles.honeypot}
tabIndex={-1}
autoComplete="off"
defaultValue=""
/>
<div className={styles.actions}>
<button type="submit" className={styles.btnPrimary}>
Send
</button>
<button type="button" className={styles.btnSecondary} onClick={handleCancel}>
Cancel
</button>
</div>
<p className={styles.disclaimer}>One-way signal — no reply will be sent.</p>
</form>
)}
{phase === "submitting" && <div className={styles.status}>Sending…</div>}
{phase === "done" && (
<div className={styles.status}>
Thanks for the feedback! ✓
<button
className={styles.dismissBtn}
onClick={handleDismiss}
aria-label="Dismiss"
>
✕
</button>
</div>
)}
{phase === "error" && (
<div className={styles.statusError}>
Could not send.
<button
className={styles.dismissBtn}
onClick={handleDismiss}
aria-label="Dismiss"
>
✕
</button>
</div>
)}
</div>
);
}

Ce composant React affiche un widget de signalement de typos qui apparaît quand l'utilisateur sélectionne du texte dans un article. Il gère un flux multi-étapes (sélection du type de problème, confirmation avec commentaire optionnel, envoi) et inclut des protections côté client comme le rate-limiting par heure et la déduplication des signalements basée sur un hash du contenu.

Trois points à noter :

  • touchend est enregistré en plus de mouseup pour que le widget fonctionne sur les appareils tactiles.
  • handleTypeSelect exécute les vérifications de rate limit et de déduplication au moment du choix du type, pas à la sélection — cela évite de consommer un slot de rate limit quand l'utilisateur sélectionne simplement du texte pour le copier.
  • Le paragraphe disclaimer (« Signal à sens unique — aucune réponse ne sera envoyée ») est volontaire : le dire explicitement évite la frustration.

Le module CSS utilise partout les propriétés personnalisées --ifm-* de Docusaurus, donc le widget suit votre thème et le mode sombre sans effort supplémentaire.


Partie 3 — L'intégration dans Docusaurus​

C'est ici qu'intervient le swizzling de Docusaurus. Plutôt que de modifier directement le fichier du thème en amont, Docusaurus vous permet de copier n'importe quel composant du thème dans src/theme/ et de le surcharger. Si vous n'avez pas encore swizzlé BlogPostItem, lancez :

>user@machine: ~/yourproject

Cela crée src/theme/BlogPostItem/index.js. Ouvrez-le et ajoutez :

// existing imports…
import TypoReport from "@site/src/components/TypoReport";
export default function BlogPostItem({ children, className }) {
const { metadata, isBlogPostPage } = useBlogPost();
// …
return (
<>
<BlogPostItemContainer className={clsx(containerClassName, className)}>
<BlogPostItemHeader />
<BlogPostItemContent>{children}</BlogPostItemContent>
<BlogPostItemFooter />
{isBlogPostPage && (
<TypoReport metadata={metadata} />
)}
</BlogPostItemContainer>
</>
);
}

Ce composant affiche un article de blog avec son en-tête, contenu et pied de page. Il intègre conditionnellement un composant TypoReport qui ne s'affiche que quand on consulte la page complète de l'article, permettant aux lecteurs de signaler des fautes de frappe.

Le garde isBlogPostPage est essentiel — sans lui, le composant se monterait aussi sur la page de liste du blog, où il n'y a pas d'élément article.


Partie 4 — Le dashboard admin​

L'endpoint admin PHP (GET ?admin=TOKEN) renvoie du JSON brut. Plutôt que de lire cette réponse brute, une petite page React sur /typo-dashboard la présente dans un format lisible : elle aplatit tous les tableaux de signalements par slug en une seule liste triée du plus récent au plus ancien, et affiche des compteurs récapitulatifs par type en haut.

Le dashboard est protégé par token : il réutilise la même clé reactions_admin_token dans localStorage que le dashboard des réactions, donc vous ne vous authentifiez qu'une fois pour les deux.

Si vous avez une page d'accueil admin, ajoutez cette entrée à son tableau TOOLS dans src/pages/admin.js :

{
icon: "🔤",
title: "Feedback Reports",
description: "Reader-flagged issues — typos, incorrect info, outdated content, suggestions.",
href: "/typo-dashboard",
accent: "#e07b00",
},

Cet objet définit une carte de navigation pour un tableau de bord de signalements. Il contient un émoji comme icône visuelle, un titre descriptif, une explication de son utilité, un lien vers la page correspondante, et une couleur d'accent pour l'interface.


Créer le composant​

Créez ces fichiers dans votre site Docusaurus, puis copiez api/.env.example vers api/.env et collez-y les deux secrets générés plus haut :

📦 Mise en place du projet : /your_docusaurus_site
Aide

Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.

Montez ensuite le composant dans src/theme/BlogPostItem/index.js comme montré en partie 3.

Déploiement​

L'API utilise declare(strict_types=1) et un type de retour never sur jsonError(), donc votre serveur doit tourner en PHP 8.1 ou plus récent :

>user@machine: ~/yourproject

Les trois fichiers JSON doivent être accessibles en écriture par le process du serveur web :

>user@machine: ~/yourproject

Une fois déployé, vérifiez l'aller-retour complet avant d'annoncer la fonctionnalité :

# 1. Fetch a nonce
NONCE=$(curl -s "https://yourdomain.com/api/typo.php?nonce" | jq -r .nonce)
# 2. Submit a test report
curl -s -X POST "https://yourdomain.com/api/typo.php" \
-H "Content-Type: application/json" \
-d "{\"slug\":\"test/smoke\",\"text\":\"hello world\",\"type\":\"typo\",\"comment\":\"\",\"context\":\"\",\"website\":\"\",\"nonce\":\"$NONCE\"}"
# → {"ok":true}
# 3. Confirm the admin endpoint
curl -s "https://yourdomain.com/api/typo.php?admin=YOUR_TOKEN" | jq .
# → {"test/smoke": [{"type":"typo",...}]}
# 4. Confirm .htaccess is protecting the file
curl -I "https://yourdomain.com/api/typo-data.json"
# → HTTP/1.1 403 Forbidden
# 5. Confirm POST without a nonce is rejected
curl -s -X POST "https://yourdomain.com/api/typo.php" \
-H "Content-Type: application/json" \
-d '{"slug":"test","text":"hello world","type":"typo","comment":"","context":"","website":""}'
# → {"error":"Invalid nonce"}
# 6. Confirm an invalid type is rejected
curl -s -X POST "https://yourdomain.com/api/typo.php" \
-H "Content-Type: application/json" \
-d "{\"slug\":\"test\",\"text\":\"hello world\",\"type\":\"question\",\"comment\":\"\",\"context\":\"\",\"website\":\"\",\"nonce\":\"$NONCE\"}"
# → {"error":"Invalid type"}

Ce script teste une API de signalement de typos en effectuant plusieurs vérifications : d'abord il récupère un jeton de sécurité (nonce) et l'utilise pour soumettre un rapport valide, puis il vérifie que l'endpoint admin fonctionne, que les données sont protégées, et qu'enfin les validations de sécurité rejettent bien les requêtes malformées ou non autorisées.

Nettoyez après le test :

echo '{}' > api/typo-data.json
echo '{}' > api/typo-ratelimit.json
echo '{}' > api/typo-notifications.json

Ce script initialise trois fichiers JSON dans le répertoire api en les créant avec un objet vide comme contenu. C'est une étape de configuration typique pour préparer l'environnement avant le démarrage d'une application qui utilise ces fichiers pour stocker des données.


Conception de la sécurité — couche par couche (sautez si vous voulez juste que ça tourne)​

Chaque POST traverse neuf couches indépendantes avant qu'un signalement ne soit écrit.

Couche 1 — CORS​

Seuls https://www.yourdomain.com et http://localhost:3000 (dev) sont autorisés comme Origin. Toute autre origine reçoit un 403 silencieux. Les requêtes same-origin n'envoient pas d'en-tête Origin et passent.

Couche 2 — Whitelist des méthodes​

L'API ne répond qu'à GET (nonce + admin), POST (envoi) et OPTIONS (preflight). Tout le reste reçoit 405 Method Not Allowed.

Couche 3 — Nonce HMAC​

Sans cela, quiconque connaît l'URL de votre API peut envoyer des signalements arbitraires avec une simple commande curl. Le nonce est un token HMAC basé sur le temps :

$nonce = hash_hmac('sha256', floor(time() / 900) . '|typo-report', NONCE_SECRET);

Le token est valable pour la fenêtre de 15 minutes en cours et la précédente (pour éviter une expiration gênante quand un utilisateur récupère le nonce à la minute 14 et envoie à la minute 15). Il n'y a aucun stockage — le token s'auto-valide. Chaque POST doit inclure cette valeur dans son body.

Couche 4 — Validation des entrées, whitelist de types et honeypot​

Le honeypot est un <input name="website"> caché, invisible pour les humains mais rempli par les bots. Si website !== "", l'API renvoie 200 OK sans rien stocker.

Les vraies entrées sont ensuite validées :

  • slug — alphanumérique, tirets, slashes, 200 caractères max
  • text — 3 à 150 caractères, Unicode imprimable uniquement (\P{C} — pas de caractères de contrôle)
  • type — doit valoir typo, incorrect, outdated ou suggestion (toute autre valeur → 400)
  • comment — facultatif, 300 caractères max, même filtre de caractères
  • context — facultatif, 300 caractères max, trimmé

Couche 5 — Rate limit global​

Une fenêtre glissante de 60 secondes avec un maximum de 20 requêtes toutes IP confondues. Cela protège des pics soudains. L'état de la fenêtre est stocké sous une clé __global__ dans typo-ratelimit.json, mis à jour dans un unique bloc LOCK_EX.

Couche 6 — Rate limits par IP​

Deux limites suivies indépendamment :

  • 10 signalements max par IP et par heure glissante
  • 3 signalements max par IP, par article, par 24 heures

Les adresses IP ne sont jamais stockées en clair. Elles sont hashées avec un sel secret : hash('sha256', IP_HASH_SALT . $ip). À sens unique — vous ne pouvez pas retrouver l'IP d'origine — mais suffisamment cohérent pour agréger.

Le script lit REMOTE_ADDR, jamais X-Forwarded-For : sur un hébergement mutualisé, X-Forwarded-For peut être falsifié par le client, ce qui permettrait à une seule IP d'en usurper des milliers et de contourner les limites par IP.

Couche 7 — Déduplication​

Un hash SHA-256 de slug + "|" + lowercase(text) est stocké dans text_hash sur chaque signalement. Avant l'écriture, les signalements existants pour ce slug sont parcourus. Si le hash existe déjà, l'API renvoie 200 OK sans rien stocker. Cela absorbe silencieusement les envois en double.

Couche 8 — Écritures sous verrou de fichier​

PHP sur hébergement mutualisé est multi-process. Chaque écriture utilise fopen + flock(LOCK_EX) + ftruncate + rewind + fwrite + flock(LOCK_UN) + fclose. La vérification du rate limit et l'écriture se font dans le même verrou pour éviter les races TOCTOU.

Couche 9 — Throttle des emails​

Vous ne voulez pas d'une boîte mail inondée si un article populaire récolte 50 signalements en une heure. Le système de notification consulte typo-notifications.json pour connaître le timestamp du dernier email par article. Un nouvel email n'est envoyé que si plus de 6 heures se sont écoulées depuis le précédent.


Limitations connues​

Débordement du tooltip sur le bord gauche​

Le clamp Math.min(left, window.innerWidth - 296) empêche le formulaire de 280px de sortir de l'écran à droite. Un débordement à gauche (sélection près du bord gauche sur des viewports très étroits) n'est pas géré.

NAT en hébergement mutualisé​

Les utilisateurs derrière le même NAT (par exemple le Wi-Fi d'un bureau) partagent la même REMOTE_ADDR. Si trois personnes différentes du même bureau signalent des problèmes sur le même article en 24 heures, la quatrième sera bloquée par le rate limit. C'est un compromis acceptable vu la contrainte de l'hébergement mutualisé.


Conclusion​

Les lecteurs n'ouvriront jamais une issue GitHub pour vous dire qu'une commande est obsolète — mais ils sélectionneront une phrase et cliqueront sur un bouton. Avec environ 250 lignes de PHP et 300 lignes de React, chacun de ces signaux arrive désormais dans votre boîte mail, avec la phrase exacte et son contexte, sans base de données ni service tiers.

L'architecture est volontairement simple : chaque morceau peut être compris, débogué et modifié en lisant un seul fichier. Si vous voulez aussi que vos lecteurs vous disent ce qu'ils ont aimé, le composant Reaction utilise exactement le même schéma PHP + JSON.


Cet article fait partie de la série Créer des composants Docusaurus :
Cet article vous a-t-il été utile ?
Avez-vous suivi les étapes de cet article ?