Le projet : arrêter de tout jeter
Au projet précédent, on a appris à recevoir et valider proprement côté serveur. Mais avoue : tout ce qu'on validait si soigneusement, on le jetait juste après. Le message partait dans le vide. Aujourd'hui, on franchit la marche qui change tout : on garde.
Le prétexte est un grand classique du web, le livre d'or : les visiteurs laissent un mot, et ces mots survivent au rechargement, à la fermeture du navigateur, à tout. Pour ça, il faut une base de données. On va prendre la plus simple qui soit, SQLite (une base entière tient dans un seul fichier, aucun serveur à installer), et on va y écrire puis y relire. C'est ce qu'on appelle du CRUD (Create, Read, Update, Delete) : lire et écrire des données, c'est, très littéralement, ce que font 80 % des applications du monde.
Comme le projet 10, ce projet tourne sur un serveur PHP (le tien, ou un PHP local). On ne peut pas juste double-cliquer le fichier. Et cette fois, le serveur doit aussi pouvoir écrire un fichier sur le disque : retiens cette phrase, elle va nous coûter dix minutes plus bas.
Le journal de bord (la vraie vie)
Comme pour le capstone, on te montre la boucle telle qu'elle s'est vraiment passée : ce qui a marché du premier coup, et surtout les deux endroits où ça a coincé.
Prompt 1 — on demande, large
En PHP, fais-moi un livre d'or : un formulaire (prénom + message) qui enregistre dans une base SQLite, et affiche en dessous tous les messages déjà laissés, du plus récent au plus ancien.
L'IA sort un fichier qui marche immédiatement : je signe, je recharge, mon message est toujours là. Magique. Sauf qu'en lisant le code, une ligne me fait tiquer : pour enregistrer, elle a collé directement ce que tape le visiteur à l'intérieur de la requête SQL.
La ligne qui pose problème :
// ⚠️ la saisie du visiteur est collée TELLE QUELLE dans la requête
$pdo->exec("INSERT INTO messages (name, body) VALUES ('$name', '$body')");
Pour voir si c'est grave, je signe le livre d'or avec un prénom un peu spécial : x', ''); DROP TABLE messages;--. Au lieu d'être enregistré comme un nom rigolo, ce texte est compris comme du SQL et exécuté. La requête se transforme en deux ordres : insérer, puis supprimer la table. Tout le livre d'or part à la poubelle. C'est l'injection SQL, et c'est exactement la même faille de fond que le XSS du projet 10 : une frontière effacée entre la donnée et le code.
Même sans rien détruire, l'injection permet de lire ce qu'on ne devrait pas : un classique est de taper ' OR '1'='1 dans un champ de connexion pour faire répondre « vrai » à la base et passer sans mot de passe. Concaténer une saisie dans du SQL, c'est tendre le micro à l'inconnu.
Prompt 2 — recadrer sur les requêtes paramétrées
Ne colle jamais les valeurs dans la requête. Utilise une requête PRÉPARÉE avec des paramètres (prepare + execute), pour que la saisie ne puisse jamais être interprétée comme du SQL.
Et là, le code devient sûr. Les ? sont des emplacements : la requête (le code) part d'un côté, les valeurs (les données) partent de l'autre, et la base les recolle elle-même sans jamais relire les données comme des instructions. Mon prénom piégé redevient un simple texte, stocké tel quel, totalement inoffensif.
// ✅ la requête et les données voyagent SÉPARÉMENT
$stmt = $pdo->prepare('INSERT INTO messages (name, body, created_at) VALUES (?, ?, ?)');
$stmt->execute([$name, $body, date('c')]);
Le bug bête — « unable to open database file »
Je mets le tout en ligne, j'envoie un message… et le serveur me crache une erreur : unable to open database file. Dix minutes à fixer un écran. Le code est pourtant bon. Le coupable : l'IA avait mis la base à côté du fichier PHP, dans un dossier où le serveur a le droit de lire mais pas d'écrire. Une base de données, ce n'est pas qu'une idée abstraite : c'est un fichier réel, et écrire un fichier demande la permission d'écrire.
Ça m'oblige à prendre une décision que l'IA ne prendra jamais à ma place : où vit la base ? Pour cette démo, je la range dans le dossier temporaire du serveur. Deux bénéfices d'un coup : c'est un endroit toujours inscriptible, et c'est hors du dossier public, donc personne ne peut télécharger le fichier de base en tapant son adresse.
// La base est un fichier. On le pose hors du dossier public, là où on a le droit d'écrire.
$db_path = sys_get_temp_dir() . '/wd_livre_or.sqlite';
$pdo = new PDO('sqlite:' . $db_path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // toute erreur SQL lève une exception
Ce genre de bug (un problème de droits sur un fichier) ne se devine pas dans le code : il vient de l'environnement (le serveur, ses permissions). C'est typiquement ce qu'on ne résout qu'en lisant le message d'erreur et en cherchant soi-même. L'IA peut t'aider à le lire, mais c'est toi qui connais ton hébergement.
Le dernier réflexe — relire ce qu'on réaffiche
Le livre d'or relit la base pour afficher tous les messages. Or ces messages viennent d'inconnus. Si l'un d'eux a écrit <script>…</script>, et que je le réaffiche tel quel, le script s'exécute chez chaque visiteur qui ouvre la page. C'est le même réflexe qu'au projet 10, mais en pire : ici la charge est stockée, donc elle frappe tout le monde, longtemps. On échappe donc chaque valeur à l'affichage avec htmlspecialchars.
// chaque message relu en base est échappé AVANT d'être affiché
<p class="entry-body"><?= htmlspecialchars($m['body'], ENT_QUOTES, 'UTF-8') ?></p>
Bilan : une faille évitée de justesse (l'injection SQL), un bug d'environnement (les droits du fichier), et un réflexe d'affichage (le XSS stocké). Le code final tient en un fichier, et chaque ligne sensible porte désormais sa parade.
Les réflexes « base de données » à retenir
Trois principes, et ils valent pour n'importe quelle base, dans n'importe quel langage.
1. La saisie ne touche jamais le SQL
On ne colle jamais une donnée venue de l'utilisateur dans une requête. On utilise une requête paramétrée (prepare + execute avec des ?), pour que la base traite la saisie comme une valeur, jamais comme une instruction. C'est la même frontière sacrée que le textContent en JavaScript ou le htmlspecialchars en sortie : les données d'un côté, le code de l'autre.
2. Échapper en sortie, surtout quand c'est stocké
Tout ce qui ressort de la base et s'affiche doit passer par htmlspecialchars(..., ENT_QUOTES). Le piège des données stockées, c'est qu'elles sont écrites une fois et relues mille fois : un <script> glissé dans un champ frappera chaque futur visiteur. C'est le XSS stocké, plus sournois que le XSS réfléchi du projet 10.
3. La base est un fichier sensible
Le fichier de base vit hors du dossier public (sinon on pourrait le télécharger et lire toutes les données). Le serveur doit avoir le droit d'écrire là où il est posé. Et en production, on n'affiche jamais le détail d'une erreur SQL à l'écran (chemin, schéma, version) : on la journalise discrètement et on montre un message neutre. Les identifiants d'une vraie base (hôte, mot de passe) vivent dans un fichier de config hors public, jamais en dur dans la page.
Tester (comme un attaquant, pas comme un gentil visiteur)
- Signe avec un prénom contenant une apostrophe, puis avec
x'); DROP TABLE messages;--: ça doit s'enregistrer comme du texte, et la table doit toujours exister après. - Tape
<script>alert(1)</script>dans le message : il doit s'afficher comme du texte, jamais déclencher d'alerte, même après rechargement. - Recharge la page (F5) juste après avoir signé : grâce à la redirection, ton message ne doit pas être renvoyé une deuxième fois.
- Envoie un prénom vide ou un message trop long : la validation serveur doit refuser proprement.
Le rendu final
Le livre d'or, en vrai, tourne ici (base SQLite côté serveur). Signe-le, recharge : ton mot reste. Puis essaie de le casser.
Le code complet
Le fichier PHP entier, exactement celui qui tourne au-dessus. Comme au projet 10, on ne le « télécharge » pas : un fichier PHP doit être exécuté par un serveur. Copie-le dans un fichier .php sur un hébergement PHP, et la base SQLite se créera toute seule au premier message.
Voir le code complet (279 lignes)
<?php
// ============================================================================
// LIVRE D'OR PERSISTANT — un seul fichier PHP, branché sur une BASE SQLite.
//
// Différence MAJEURE avec le formulaire du projet 10 : ici, on STOCKE. Les
// messages laissés par les visiteurs survivent au rechargement parce qu'ils
// sont écrits dans une vraie base de données (un fichier SQLite). C'est le pas
// décisif vers le web "réel" : la plupart des applis ne font que lire et écrire
// des données. C'est ce qu'on appelle du CRUD (Create, Read, Update, Delete) ;
// ici on se concentre sur les deux plus courants : Create et Read.
//
// PLAN DU FICHIER :
// 1) Session + jeton CSRF (prouver que la soumission vient de NOTRE page)
// 2) Connexion à la base SQLite + création de la table si besoin
// 3) Traitement du POST : honeypot, CSRF, validation, INSERT paramétré, PRG
// 4) Lecture des messages (SELECT) pour l'affichage
// 5) Le HTML (le formulaire + la liste, chaque valeur ÉCHAPPÉE à la sortie)
//
// Démo publique : la base vit dans le dossier temporaire du serveur (hors du
// dossier public, donc non téléchargeable) et ne garde que les 50 derniers
// messages. Elle peut se réinitialiser : c'est voulu, on ne veut pas accumuler
// du spam éternellement.
// ============================================================================
session_start(); // tout en haut, AUCUNE ligne vide ni espace avant <?php (sinon "headers already sent")
// --- 1) Jeton CSRF ----------------------------------------------------------
// Un secret aléatoire gardé en session, réinjecté dans le formulaire (champ
// caché) et revérifié à la soumission. Il garantit que le POST vient bien de
// cette page, pas d'un site tiers qui aurait piégé le visiteur.
if (empty($_SESSION['livreor_csrf'])) {
$_SESSION['livreor_csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['livreor_csrf'];
// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';
$S = [
'fr' => [
'title' => "Livre d'or", 'sub' => "Laisse un mot. Il sera enregistré pour de vrai (base SQLite).",
'name' => "Ton prénom", 'body' => "Ton message", 'send' => "Signer le livre d'or",
'ph_name' => "Camille", 'ph_body' => "Un petit mot sympa…",
'err_csrf' => "Jeton de sécurité invalide. Recharge la page et réessaie.",
'err_name' => "Indique un prénom (2 caractères minimum).",
'err_body' => "Le message doit faire entre 2 et 280 caractères.",
'ok' => "Merci, ton message est enregistré ✓",
'empty' => "Personne n'a encore signé. Sois le premier !",
'count' => "message(s) enregistré(s)",
'note' => "Chaque message est inséré via une requête PARAMÉTRÉE (jamais collé dans le SQL), puis ré-affiché ÉCHAPPÉ.",
'credit' => 'Base SQLite sur un serveur PHP · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
'err_db' => "La base de données est momentanément indisponible. Réessaie plus tard.",
],
'en' => [
'title' => "Guestbook", 'sub' => "Leave a word. It will be stored for real (SQLite database).",
'name' => "Your first name", 'body' => "Your message", 'send' => "Sign the guestbook",
'ph_name' => "Camille", 'ph_body' => "A nice little note…",
'err_csrf' => "Invalid security token. Reload the page and try again.",
'err_name' => "Please enter a first name (2 characters minimum).",
'err_body' => "The message must be between 2 and 280 characters.",
'ok' => "Thanks, your message is saved ✓",
'empty' => "Nobody has signed yet. Be the first!",
'count' => "message(s) stored",
'note' => "Each message is inserted via a PARAMETERIZED query (never glued into the SQL), then re-displayed ESCAPED.",
'credit' => 'SQLite database on a PHP server · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
'err_db' => "The database is momentarily unavailable. Please try again later.",
],
][$lang];
// Échappe une valeur AVANT de l'afficher : la parade universelle contre le XSS.
// Un <script> tapé par un visiteur sera affiché comme du texte, jamais exécuté.
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
// --- 2) Connexion à la base SQLite ------------------------------------------
// La base est un simple fichier. On le place dans le dossier temporaire du
// serveur : hors du dossier public (donc impossible à télécharger par l'URL).
$db_path = sys_get_temp_dir() . '/wd_livre_or.sqlite';
$db_ok = true;
$pdo = null;
try {
$pdo = new PDO('sqlite:' . $db_path);
// ERRMODE_EXCEPTION : la moindre erreur SQL lève une exception qu'on attrape,
// au lieu de continuer en silence avec des données fausses.
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Crée la table au premier passage. "IF NOT EXISTS" = idempotent.
$pdo->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL
)");
} catch (PDOException $ex) {
// On NE montre PAS le détail de l'erreur au visiteur : un message technique
// (chemin du fichier, version, schéma) est une fuite d'information utile à
// un attaquant. On loggerait l'erreur côté serveur, et on affiche un texte neutre.
$db_ok = false;
}
$errors = [];
$v = ['name' => '', 'body' => ''];
// --- 3) Traitement de la soumission -----------------------------------------
if ($db_ok && $_SERVER['REQUEST_METHOD'] === 'POST') {
// 3a) Honeypot : un champ caché que seuls les robots remplissent.
if (!empty($_POST['website'])) {
// On fait comme si tout allait bien, sans rien écrire.
header('Location: ?lang=' . $lang . '&ok=1');
exit;
}
// 3b) Jeton CSRF (hash_equals = comparaison à temps constant, anti-timing).
if (!isset($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) {
$errors['csrf'] = $S['err_csrf'];
} else {
// 3c) Validation CÔTÉ SERVEUR (le "required" du HTML ne protège rien).
$v['name'] = trim($_POST['name'] ?? '');
$v['body'] = trim($_POST['body'] ?? '');
if (mb_strlen($v['name']) < 2 || mb_strlen($v['name']) > 40) $errors['name'] = $S['err_name'];
$blen = mb_strlen($v['body']);
if ($blen < 2 || $blen > 280) $errors['body'] = $S['err_body'];
// 3d) Insertion PARAMÉTRÉE. Les "?" sont des emplacements : les valeurs
// voyagent À PART de la requête, elles ne peuvent JAMAIS être prises
// pour du SQL. C'est la parade définitive contre l'injection SQL.
if (!$errors) {
try {
$stmt = $pdo->prepare('INSERT INTO messages (name, body, created_at) VALUES (?, ?, ?)');
$stmt->execute([$v['name'], $v['body'], date('c')]);
// On ne garde que les 50 messages les plus récents (anti-accumulation).
$pdo->exec('DELETE FROM messages WHERE id NOT IN (SELECT id FROM messages ORDER BY id DESC LIMIT 50)');
// PRG (Post / Redirect / Get) : on redirige après écriture pour
// qu'un rafraîchissement (F5) ne renvoie pas une 2e fois le message.
header('Location: ?lang=' . $lang . '&ok=1');
exit;
} catch (PDOException $ex) {
$errors['db'] = $S['err_db'];
}
}
}
}
// --- 4) Lecture des messages pour l'affichage -------------------------------
$messages = [];
if ($db_ok) {
try {
$messages = $pdo->query('SELECT name, body, created_at FROM messages ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $ex) {
$db_ok = false;
}
}
$just_saved = isset($_GET['ok']);
?>
<!DOCTYPE html>
<html lang="<?= e($lang) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($S['title']) ?></title>
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --warn:#a8341f; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:560px; }
header { text-align:center; margin-bottom:18px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; text-decoration:none; }
.lang-btn.active { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
form { background:#fff; border:1px solid var(--border); border-radius:14px; padding:22px; }
label { display:block; font-weight:700; font-size:0.9rem; margin:14px 0 6px; }
label:first-child { margin-top:0; }
input[type=text], textarea { width:100%; padding:11px 13px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
textarea { min-height:84px; resize:vertical; }
input:focus-visible, textarea:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
input.bad, textarea.bad { border-color:var(--warn); }
.field-err { color:var(--warn); font-size:0.85rem; margin:6px 0 0; }
.btn { margin-top:18px; width:100%; min-height:50px; border-radius:12px; border:0; background:var(--accent); color:#fff; font:inherit; font-weight:700; font-size:1rem; cursor:pointer; }
.btn:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.note { color:var(--muted); font-size:0.82rem; margin-top:14px; }
.alert { border-radius:10px; padding:12px 14px; margin-bottom:16px; font-size:0.92rem; }
.alert-err { background:#fdecea; color:var(--warn); border:1px solid #f3c4be; }
.alert-ok { background:#eaf6ee; color:#1f6a37; border:1px solid #bfe3cb; }
.entries { margin-top:26px; }
.entries-head { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:12px; }
.entries-head h2 { font-size:1.1rem; margin:0; }
.entries-count { color:var(--muted); font-size:0.82rem; }
.entry { background:#fff; border:1px solid var(--border); border-radius:12px; padding:14px 16px; margin-bottom:10px; }
.entry-name { font-weight:700; }
.entry-date { color:var(--muted); font-size:0.76rem; }
.entry-body { margin:6px 0 0; white-space:pre-wrap; word-break:break-word; }
.entries-empty { color:var(--muted); text-align:center; padding:18px; }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:22px; }
.credit a { color:var(--accent); }
</style>
</head>
<body>
<main class="app">
<header>
<h1><?= e($S['title']) ?></h1>
<p class="sub"><?= e($S['sub']) ?></p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<a class="lang-btn <?= $lang==='fr'?'active':'' ?>" href="?lang=fr">FR</a>
<a class="lang-btn <?= $lang==='en'?'active':'' ?>" href="?lang=en">EN</a>
</div>
</header>
<?php if (!$db_ok): ?>
<div class="alert alert-err"><?= e($S['err_db']) ?></div>
<?php else: ?>
<?php if ($just_saved): ?>
<div class="alert alert-ok"><?= e($S['ok']) ?></div>
<?php endif; ?>
<form method="post" action="">
<?php if (!empty($errors['csrf'])): ?>
<div class="alert alert-err"><?= e($errors['csrf']) ?></div>
<?php endif; ?>
<?php if (!empty($errors['db'])): ?>
<div class="alert alert-err"><?= e($errors['db']) ?></div>
<?php endif; ?>
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="lang" value="<?= e($lang) ?>">
<!-- Honeypot : invisible pour un humain, souvent rempli par un robot. -->
<div style="position:absolute;left:-9999px;" aria-hidden="true">
<label>Ne pas remplir<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<label for="name"><?= e($S['name']) ?></label>
<input type="text" id="name" name="name" value="<?= e($v['name']) ?>" placeholder="<?= e($S['ph_name']) ?>" maxlength="40" class="<?= isset($errors['name'])?'bad':'' ?>" required>
<?php if (isset($errors['name'])): ?><p class="field-err"><?= e($errors['name']) ?></p><?php endif; ?>
<label for="body"><?= e($S['body']) ?></label>
<textarea id="body" name="body" placeholder="<?= e($S['ph_body']) ?>" maxlength="280" class="<?= isset($errors['body'])?'bad':'' ?>" required><?= e($v['body']) ?></textarea>
<?php if (isset($errors['body'])): ?><p class="field-err"><?= e($errors['body']) ?></p><?php endif; ?>
<button class="btn" type="submit"><?= e($S['send']) ?></button>
<p class="note"><?= e($S['note']) ?></p>
</form>
<section class="entries">
<div class="entries-head">
<h2><?= e($S['title']) ?></h2>
<span class="entries-count"><?= count($messages) ?> <?= e($S['count']) ?></span>
</div>
<?php if (!$messages): ?>
<p class="entries-empty"><?= e($S['empty']) ?></p>
<?php else: ?>
<?php foreach ($messages as $m): ?>
<!-- CHAQUE valeur venue de la base (donc d'un inconnu) est échappée avec e(). -->
<article class="entry">
<div class="entry-name"><?= e($m['name']) ?>
<span class="entry-date"><?= e(date($lang==='fr' ? 'd/m/Y H:i' : 'Y-m-d H:i', strtotime($m['created_at']))) ?></span>
</div>
<p class="entry-body"><?= e($m['body']) ?></p>
</article>
<?php endforeach; ?>
<?php endif; ?>
</section>
<?php endif; ?>
<p class="credit"><?= $S['credit'] /* lien <a> rédigé par nous, pas par l'utilisateur */ ?></p>
</main>
</body>
</html>
À toi de jouer
- Ajoute la suppression d'un message (le « D » de CRUD). Tu verras vite la vraie question : qui a le droit de supprimer ? Bienvenue dans le besoin d'un compte.
- Ajoute une recherche dans les messages, en gardant le réflexe : la recherche aussi passe par une requête paramétrée.
- Au-delà de 50 messages, ajoute une pagination (10 par page) plutôt que tout afficher d'un coup.
À chaque ajout, la question « base » ne change pas : est-ce que je colle une saisie dans ma requête ? (jamais) Est-ce que j'échappe ce que je réaffiche ? (toujours)
Tu sais maintenant garder des données, pas seulement les valider. Et tu as vu, encore une fois, que l'IA code vite mais qu'elle laisse passer ce qui compte le plus : la frontière entre données et code. La prochaine marche logique, c'est de protéger ces données derrière un compte. Justement, c'est le projet suivant.
Projet 12 : une connexion sécurisée →The project: stop throwing everything away
In the previous project, we learned to receive and validate cleanly on the server. But admit it: everything we validated so carefully, we threw away right after. The message vanished into thin air. Today, we take the step that changes everything: we keep.
The pretext is a web classic, the guestbook: visitors leave a word, and those words survive the reload, the browser closing, everything. For that, you need a database. We'll take the simplest one there is, SQLite (a whole database fits in a single file, no server to install), and we'll write to it then read back. This is what we call CRUD (Create, Read, Update, Delete): reading and writing data is, quite literally, what 80% of the world's applications do.
Like project 10, this project runs on a PHP server (yours, or a local PHP). You can't just double-click the file. And this time, the server must also be able to write a file to disk: remember that sentence, it will cost us ten minutes below.
The logbook (real life)
As with the capstone, we show you the loop as it actually happened: what worked first try, and above all the two places where it jammed.
Prompt 1 — we ask, broadly
In PHP, make me a guestbook: a form (first name + message) that saves to a SQLite database, and displays below all the messages already left, newest to oldest.
The AI outputs a file that works immediately: I sign, I reload, my message is still there. Magic. Except, reading the code, one line makes me wince: to save, it glued directly what the visitor types inside the SQL query.
The problematic line:
// ⚠️ the visitor's input is glued AS-IS into the query
$pdo->exec("INSERT INTO messages (name, body) VALUES ('$name', '$body')");
To see if it's serious, I sign the guestbook with a slightly special first name: x', ''); DROP TABLE messages;--. Instead of being stored as a funny name, this text is understood as SQL and executed. The query becomes two orders: insert, then delete the table. The whole guestbook goes in the bin. That's SQL injection, and it's the exact same underlying flaw as the XSS in project 10: a blurred border between data and code.
Even without destroying anything, injection lets you read what you shouldn't: a classic is typing ' OR '1'='1 in a login field to make the database answer "true" and slip in without a password. Concatenating input into SQL is handing the mic to a stranger.
Prompt 2 — refocus on parameterized queries
Never glue values into the query. Use a PREPARED query with parameters (prepare + execute), so the input can never be interpreted as SQL.
And there, the code becomes safe. The ? are placeholders: the query (the code) goes one way, the values (the data) go the other, and the database glues them itself without ever re-reading the data as instructions. My booby-trapped first name becomes plain text again, stored as-is, completely harmless.
// ✅ the query and the data travel SEPARATELY
$stmt = $pdo->prepare('INSERT INTO messages (name, body, created_at) VALUES (?, ?, ?)');
$stmt->execute([$name, $body, date('c')]);
The silly bug — "unable to open database file"
I put it all online, send a message… and the server spits an error: unable to open database file. Ten minutes staring at a screen. Yet the code is fine. The culprit: the AI had put the database next to the PHP file, in a folder where the server may read but not write. A database isn't just an abstract idea: it's a real file, and writing a file requires write permission.
This forces me into a decision the AI will never make for me: where does the database live? For this demo, I tuck it into the server's temporary folder. Two benefits at once: it's always writable, and it's outside the public folder, so nobody can download the database file by typing its address.
// The database is a file. We put it outside the public folder, where we may write.
$db_path = sys_get_temp_dir() . '/wd_livre_or.sqlite';
$pdo = new PDO('sqlite:' . $db_path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // any SQL error throws an exception
This kind of bug (a file permissions problem) can't be guessed from the code: it comes from the environment (the server, its permissions). It's exactly what you only solve by reading the error message and digging yourself. The AI can help you read it, but you're the one who knows your hosting.
The last reflex — re-escape what you redisplay
The guestbook reads the database back to show all the messages. But those messages come from strangers. If one of them wrote <script>…</script>, and I redisplay it as-is, the script runs for every visitor who opens the page. It's the same reflex as project 10, but worse: here the payload is stored, so it hits everyone, for a long time. So we escape every value on display with htmlspecialchars.
// each message read from the database is escaped BEFORE being displayed
<p class="entry-body"><?= htmlspecialchars($m['body'], ENT_QUOTES, 'UTF-8') ?></p>
Tally: a flaw narrowly avoided (SQL injection), an environment bug (file permissions), and a display reflex (stored XSS). The final code fits in one file, and every sensitive line now carries its own guard.
The "database" reflexes to keep
Three principles, and they hold for any database, in any language.
1. Input never touches the SQL
You never glue user data into a query. You use a parameterized query (prepare + execute with ?), so the database treats the input as a value, never as an instruction. It's the same sacred border as textContent in JavaScript or htmlspecialchars on output: data on one side, code on the other.
2. Escape on output, especially when stored
Anything that comes out of the database and is displayed must go through htmlspecialchars(..., ENT_QUOTES). The trap with stored data is that it's written once and read back a thousand times: a <script> slipped into a field will hit every future visitor. That's stored XSS, sneakier than the reflected XSS of project 10.
3. The database is a sensitive file
The database file lives outside the public folder (otherwise it could be downloaded and all the data read). The server must have write permission where it sits. And in production, you never display the detail of a SQL error on screen (path, schema, version): you log it quietly and show a neutral message. The credentials of a real database (host, password) live in a config file outside the public folder, never hard-coded in the page.
Test (like an attacker, not a nice visitor)
- Sign with a first name containing an apostrophe, then with
x'); DROP TABLE messages;--: it must be stored as text, and the table must still exist afterwards. - Type
<script>alert(1)</script>in the message: it must show as text, never trigger an alert, even after reloading. - Reload the page (F5) right after signing: thanks to the redirect, your message must not be sent a second time.
- Send an empty first name or a too-long message: server validation must refuse cleanly.
The finished result
The guestbook, for real, runs here (SQLite database server-side). Sign it, reload: your word stays. Then try to break it.
The full code
The entire PHP file, exactly the one running above. Like project 10, you don't "download" it: a PHP file must be run by a server. Copy it into a .php file on PHP hosting, and the SQLite database will create itself on the first message.
View the full code (279 lines)
<?php
// ============================================================================
// LIVRE D'OR PERSISTANT — un seul fichier PHP, branché sur une BASE SQLite.
//
// Différence MAJEURE avec le formulaire du projet 10 : ici, on STOCKE. Les
// messages laissés par les visiteurs survivent au rechargement parce qu'ils
// sont écrits dans une vraie base de données (un fichier SQLite). C'est le pas
// décisif vers le web "réel" : la plupart des applis ne font que lire et écrire
// des données. C'est ce qu'on appelle du CRUD (Create, Read, Update, Delete) ;
// ici on se concentre sur les deux plus courants : Create et Read.
//
// PLAN DU FICHIER :
// 1) Session + jeton CSRF (prouver que la soumission vient de NOTRE page)
// 2) Connexion à la base SQLite + création de la table si besoin
// 3) Traitement du POST : honeypot, CSRF, validation, INSERT paramétré, PRG
// 4) Lecture des messages (SELECT) pour l'affichage
// 5) Le HTML (le formulaire + la liste, chaque valeur ÉCHAPPÉE à la sortie)
//
// Démo publique : la base vit dans le dossier temporaire du serveur (hors du
// dossier public, donc non téléchargeable) et ne garde que les 50 derniers
// messages. Elle peut se réinitialiser : c'est voulu, on ne veut pas accumuler
// du spam éternellement.
// ============================================================================
session_start(); // tout en haut, AUCUNE ligne vide ni espace avant <?php (sinon "headers already sent")
// --- 1) Jeton CSRF ----------------------------------------------------------
// Un secret aléatoire gardé en session, réinjecté dans le formulaire (champ
// caché) et revérifié à la soumission. Il garantit que le POST vient bien de
// cette page, pas d'un site tiers qui aurait piégé le visiteur.
if (empty($_SESSION['livreor_csrf'])) {
$_SESSION['livreor_csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['livreor_csrf'];
// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';
$S = [
'fr' => [
'title' => "Livre d'or", 'sub' => "Laisse un mot. Il sera enregistré pour de vrai (base SQLite).",
'name' => "Ton prénom", 'body' => "Ton message", 'send' => "Signer le livre d'or",
'ph_name' => "Camille", 'ph_body' => "Un petit mot sympa…",
'err_csrf' => "Jeton de sécurité invalide. Recharge la page et réessaie.",
'err_name' => "Indique un prénom (2 caractères minimum).",
'err_body' => "Le message doit faire entre 2 et 280 caractères.",
'ok' => "Merci, ton message est enregistré ✓",
'empty' => "Personne n'a encore signé. Sois le premier !",
'count' => "message(s) enregistré(s)",
'note' => "Chaque message est inséré via une requête PARAMÉTRÉE (jamais collé dans le SQL), puis ré-affiché ÉCHAPPÉ.",
'credit' => 'Base SQLite sur un serveur PHP · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
'err_db' => "La base de données est momentanément indisponible. Réessaie plus tard.",
],
'en' => [
'title' => "Guestbook", 'sub' => "Leave a word. It will be stored for real (SQLite database).",
'name' => "Your first name", 'body' => "Your message", 'send' => "Sign the guestbook",
'ph_name' => "Camille", 'ph_body' => "A nice little note…",
'err_csrf' => "Invalid security token. Reload the page and try again.",
'err_name' => "Please enter a first name (2 characters minimum).",
'err_body' => "The message must be between 2 and 280 characters.",
'ok' => "Thanks, your message is saved ✓",
'empty' => "Nobody has signed yet. Be the first!",
'count' => "message(s) stored",
'note' => "Each message is inserted via a PARAMETERIZED query (never glued into the SQL), then re-displayed ESCAPED.",
'credit' => 'SQLite database on a PHP server · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
'err_db' => "The database is momentarily unavailable. Please try again later.",
],
][$lang];
// Échappe une valeur AVANT de l'afficher : la parade universelle contre le XSS.
// Un <script> tapé par un visiteur sera affiché comme du texte, jamais exécuté.
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
// --- 2) Connexion à la base SQLite ------------------------------------------
// La base est un simple fichier. On le place dans le dossier temporaire du
// serveur : hors du dossier public (donc impossible à télécharger par l'URL).
$db_path = sys_get_temp_dir() . '/wd_livre_or.sqlite';
$db_ok = true;
$pdo = null;
try {
$pdo = new PDO('sqlite:' . $db_path);
// ERRMODE_EXCEPTION : la moindre erreur SQL lève une exception qu'on attrape,
// au lieu de continuer en silence avec des données fausses.
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Crée la table au premier passage. "IF NOT EXISTS" = idempotent.
$pdo->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL
)");
} catch (PDOException $ex) {
// On NE montre PAS le détail de l'erreur au visiteur : un message technique
// (chemin du fichier, version, schéma) est une fuite d'information utile à
// un attaquant. On loggerait l'erreur côté serveur, et on affiche un texte neutre.
$db_ok = false;
}
$errors = [];
$v = ['name' => '', 'body' => ''];
// --- 3) Traitement de la soumission -----------------------------------------
if ($db_ok && $_SERVER['REQUEST_METHOD'] === 'POST') {
// 3a) Honeypot : un champ caché que seuls les robots remplissent.
if (!empty($_POST['website'])) {
// On fait comme si tout allait bien, sans rien écrire.
header('Location: ?lang=' . $lang . '&ok=1');
exit;
}
// 3b) Jeton CSRF (hash_equals = comparaison à temps constant, anti-timing).
if (!isset($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) {
$errors['csrf'] = $S['err_csrf'];
} else {
// 3c) Validation CÔTÉ SERVEUR (le "required" du HTML ne protège rien).
$v['name'] = trim($_POST['name'] ?? '');
$v['body'] = trim($_POST['body'] ?? '');
if (mb_strlen($v['name']) < 2 || mb_strlen($v['name']) > 40) $errors['name'] = $S['err_name'];
$blen = mb_strlen($v['body']);
if ($blen < 2 || $blen > 280) $errors['body'] = $S['err_body'];
// 3d) Insertion PARAMÉTRÉE. Les "?" sont des emplacements : les valeurs
// voyagent À PART de la requête, elles ne peuvent JAMAIS être prises
// pour du SQL. C'est la parade définitive contre l'injection SQL.
if (!$errors) {
try {
$stmt = $pdo->prepare('INSERT INTO messages (name, body, created_at) VALUES (?, ?, ?)');
$stmt->execute([$v['name'], $v['body'], date('c')]);
// On ne garde que les 50 messages les plus récents (anti-accumulation).
$pdo->exec('DELETE FROM messages WHERE id NOT IN (SELECT id FROM messages ORDER BY id DESC LIMIT 50)');
// PRG (Post / Redirect / Get) : on redirige après écriture pour
// qu'un rafraîchissement (F5) ne renvoie pas une 2e fois le message.
header('Location: ?lang=' . $lang . '&ok=1');
exit;
} catch (PDOException $ex) {
$errors['db'] = $S['err_db'];
}
}
}
}
// --- 4) Lecture des messages pour l'affichage -------------------------------
$messages = [];
if ($db_ok) {
try {
$messages = $pdo->query('SELECT name, body, created_at FROM messages ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $ex) {
$db_ok = false;
}
}
$just_saved = isset($_GET['ok']);
?>
<!DOCTYPE html>
<html lang="<?= e($lang) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($S['title']) ?></title>
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --warn:#a8341f; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:560px; }
header { text-align:center; margin-bottom:18px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; text-decoration:none; }
.lang-btn.active { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
form { background:#fff; border:1px solid var(--border); border-radius:14px; padding:22px; }
label { display:block; font-weight:700; font-size:0.9rem; margin:14px 0 6px; }
label:first-child { margin-top:0; }
input[type=text], textarea { width:100%; padding:11px 13px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
textarea { min-height:84px; resize:vertical; }
input:focus-visible, textarea:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
input.bad, textarea.bad { border-color:var(--warn); }
.field-err { color:var(--warn); font-size:0.85rem; margin:6px 0 0; }
.btn { margin-top:18px; width:100%; min-height:50px; border-radius:12px; border:0; background:var(--accent); color:#fff; font:inherit; font-weight:700; font-size:1rem; cursor:pointer; }
.btn:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.note { color:var(--muted); font-size:0.82rem; margin-top:14px; }
.alert { border-radius:10px; padding:12px 14px; margin-bottom:16px; font-size:0.92rem; }
.alert-err { background:#fdecea; color:var(--warn); border:1px solid #f3c4be; }
.alert-ok { background:#eaf6ee; color:#1f6a37; border:1px solid #bfe3cb; }
.entries { margin-top:26px; }
.entries-head { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:12px; }
.entries-head h2 { font-size:1.1rem; margin:0; }
.entries-count { color:var(--muted); font-size:0.82rem; }
.entry { background:#fff; border:1px solid var(--border); border-radius:12px; padding:14px 16px; margin-bottom:10px; }
.entry-name { font-weight:700; }
.entry-date { color:var(--muted); font-size:0.76rem; }
.entry-body { margin:6px 0 0; white-space:pre-wrap; word-break:break-word; }
.entries-empty { color:var(--muted); text-align:center; padding:18px; }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:22px; }
.credit a { color:var(--accent); }
</style>
</head>
<body>
<main class="app">
<header>
<h1><?= e($S['title']) ?></h1>
<p class="sub"><?= e($S['sub']) ?></p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<a class="lang-btn <?= $lang==='fr'?'active':'' ?>" href="?lang=fr">FR</a>
<a class="lang-btn <?= $lang==='en'?'active':'' ?>" href="?lang=en">EN</a>
</div>
</header>
<?php if (!$db_ok): ?>
<div class="alert alert-err"><?= e($S['err_db']) ?></div>
<?php else: ?>
<?php if ($just_saved): ?>
<div class="alert alert-ok"><?= e($S['ok']) ?></div>
<?php endif; ?>
<form method="post" action="">
<?php if (!empty($errors['csrf'])): ?>
<div class="alert alert-err"><?= e($errors['csrf']) ?></div>
<?php endif; ?>
<?php if (!empty($errors['db'])): ?>
<div class="alert alert-err"><?= e($errors['db']) ?></div>
<?php endif; ?>
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="lang" value="<?= e($lang) ?>">
<!-- Honeypot : invisible pour un humain, souvent rempli par un robot. -->
<div style="position:absolute;left:-9999px;" aria-hidden="true">
<label>Ne pas remplir<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<label for="name"><?= e($S['name']) ?></label>
<input type="text" id="name" name="name" value="<?= e($v['name']) ?>" placeholder="<?= e($S['ph_name']) ?>" maxlength="40" class="<?= isset($errors['name'])?'bad':'' ?>" required>
<?php if (isset($errors['name'])): ?><p class="field-err"><?= e($errors['name']) ?></p><?php endif; ?>
<label for="body"><?= e($S['body']) ?></label>
<textarea id="body" name="body" placeholder="<?= e($S['ph_body']) ?>" maxlength="280" class="<?= isset($errors['body'])?'bad':'' ?>" required><?= e($v['body']) ?></textarea>
<?php if (isset($errors['body'])): ?><p class="field-err"><?= e($errors['body']) ?></p><?php endif; ?>
<button class="btn" type="submit"><?= e($S['send']) ?></button>
<p class="note"><?= e($S['note']) ?></p>
</form>
<section class="entries">
<div class="entries-head">
<h2><?= e($S['title']) ?></h2>
<span class="entries-count"><?= count($messages) ?> <?= e($S['count']) ?></span>
</div>
<?php if (!$messages): ?>
<p class="entries-empty"><?= e($S['empty']) ?></p>
<?php else: ?>
<?php foreach ($messages as $m): ?>
<!-- CHAQUE valeur venue de la base (donc d'un inconnu) est échappée avec e(). -->
<article class="entry">
<div class="entry-name"><?= e($m['name']) ?>
<span class="entry-date"><?= e(date($lang==='fr' ? 'd/m/Y H:i' : 'Y-m-d H:i', strtotime($m['created_at']))) ?></span>
</div>
<p class="entry-body"><?= e($m['body']) ?></p>
</article>
<?php endforeach; ?>
<?php endif; ?>
</section>
<?php endif; ?>
<p class="credit"><?= $S['credit'] /* lien <a> rédigé par nous, pas par l'utilisateur */ ?></p>
</main>
</body>
</html>
Your turn
- Add the deletion of a message (the "D" in CRUD). You'll quickly see the real question: who is allowed to delete? Welcome to the need for an account.
- Add a search through the messages, keeping the reflex: search too goes through a parameterized query.
- Beyond 50 messages, add pagination (10 per page) rather than showing everything at once.
On every addition, the "database" question doesn't change: do I glue input into my query? (never) Do I escape what I redisplay? (always)
You now know how to keep data, not just validate it. And you saw, once again, that the AI codes fast but lets through what matters most: the border between data and code. The next logical step is to protect that data behind an account. That's exactly the next project.
Project 12: a secure login →Your guestbook keeps every message in a SQLite database, nothing gets lost. For the last project, you tackle the most serious topic: a secure login, with passwords handled for real.
Lesson 12: Secure login →