Abschnitt 18 · Lektion 7
Bau dir was
Nach einer Woche mit fünfzig Lesezeichen fehlt der Anwendung einiges, das keine der fünf Aufgaben vorgesehen hat, eine Sortierung nach Titel, Seiten statt einer endlosen Liste oder ein Satz zu jedem Eintrag. Nichts davon braucht etwas Neues. Ob so eine Erweiterung tut, was du willst, weiß aber vorher keine Prüfliste, und deshalb hat diese Lektion keine Aufgabe.
Die fertige Anwendung
<?php
declare(strict_types=1);
// Was sich zwischen deinem Rechner und einem Server unterscheidet, als
// Datei, die ein Array zurueckgibt, wie in Lektion 16.5. Sie liegt
// ausserhalb von oeffentlich/.
return [
"datenbank" => __DIR__ . "/privat/daten.db",
"titel" => "Lesezeichen",
]; <?php
declare(strict_types=1);
// Die Helfer der Anwendung, jeder aus einer frueheren Lektion:
// e() maskiert (11.4 und 15.2), rendere() fuehrt eine Vorlage in einem
// eigenen Raum aus (16.2), passtRoute() vergleicht einen Pfad mit einem
// Muster (16.4), token() erzeugt das CSRF-Token (15.3), merkeMeldung() und
// holeMeldung() tragen eine Meldung ueber die Weiterleitung (12.4).
function e(?string $wert): string
{
return htmlspecialchars($wert ?? "", ENT_QUOTES, "UTF-8");
}
function rendere(string $datei, array $daten = []): string
{
extract($daten);
ob_start();
require $datei;
return ob_get_clean();
}
function passtRoute(string $muster, string $pfad): ?array
{
$regex = "#^" . str_replace('\{id\}', '([0-9]+)', preg_quote($muster, "#")) . "$#";
if (preg_match($regex, $pfad, $treffer) !== 1) {
return null;
}
return array_slice($treffer, 1);
}
function token(): string
{
if (empty($_SESSION["token"])) {
$_SESSION["token"] = bin2hex(random_bytes(16));
}
return $_SESSION["token"];
}
function merkeMeldung(string $text): void
{
$_SESSION["meldung"] = $text;
}
function holeMeldung(): ?string
{
$gemerkt = $_SESSION["meldung"] ?? null;
unset($_SESSION["meldung"]);
return $gemerkt;
}
function weiterZu(string $pfad): void
{
header("Location: " . $pfad, true, 302);
exit;
}
// Eine Fehlerseite kommt ohne das Layout aus: Sie soll kurz sein, und wer
// hier landet, darf die Navigation der Anwendung gerade nicht sehen.
function beendeMit(int $code, string $ueberschrift): void
{
http_response_code($code);
echo '<!doctype html><html lang="de"><head><meta charset="utf-8">',
"<title>", e($ueberschrift), "</title></head><body>",
"<h1>", e($ueberschrift), "</h1>",
'<p><a href="/liste">Zurück zur Liste</a></p></body></html>';
exit;
} <?php
declare(strict_types=1);
// Welche Adresse zu welcher Seitenfunktion gehoert. Das Muster {id} steht
// fuer eine Zahl, und passtRoute() gibt sie an die Funktion weiter.
return [
["GET", "/", "seiteStart"],
["GET", "/login", "seiteAnmelden"],
["POST", "/login", "aktionAnmelden"],
["POST", "/registrieren", "aktionRegistrieren"],
["GET", "/logout", "aktionAbmelden"],
["GET", "/liste", "seiteListe"],
["POST", "/lesezeichen", "aktionAnlegen"],
["GET", "/suche", "seiteSuche"],
["GET", "/lesezeichen/{id}/bearbeiten", "seiteBearbeiten"],
["POST", "/lesezeichen/{id}/speichern", "aktionSpeichern"],
["POST", "/lesezeichen/{id}/loeschen", "aktionLoeschen"],
]; <?php
declare(strict_types=1);
namespace App;
use PDO;
// Die Speicherklasse, die einzige Datei mit SQL. Aus den Aufgaben stammen
// die beiden Tabellen, alleVon(), holen(), anlegen() und loeschen() (18.2)
// sowie fuerLike() und suchen() (18.6). Gegeben waren nutzerAnlegen(),
// nutzerNachName(), aendern() und die Startdaten.
class LesezeichenSpeicher
{
private PDO $db;
public function __construct(string $datei)
{
if (!is_dir(dirname($datei))) {
mkdir(dirname($datei), 0777, true);
}
$this->db = new PDO("sqlite:" . $datei, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->exec("CREATE TABLE IF NOT EXISTS nutzer (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
passwort_hash TEXT NOT NULL
)");
$this->db->exec("CREATE TABLE IF NOT EXISTS lesezeichen (
id INTEGER PRIMARY KEY,
nutzer_id INTEGER NOT NULL REFERENCES nutzer(id),
titel TEXT NOT NULL,
url TEXT NOT NULL
)");
$this->startdaten();
}
// Zwei Konten, damit niemand bei jedem Lauf ein neues anlegen muss. mia
// hat das Passwort gurkenbeet-im-mai, tom hat zaun-streichen-heute. Auf
// dem eigenen Rechner faellt der Aufruf im Konstruktor weg, dann faengt
// die Datenbank leer an. Die Hashes stehen in einfachen
// Anfuehrungszeichen, denn in doppelten sieht PHP hinter jedem
// Dollarzeichen eine Variable, und vom Hash bliebe ein Stummel.
private function startdaten(): void
{
if ((int) $this->db->query("SELECT count(*) FROM nutzer")->fetchColumn() > 0) {
return;
}
$anlegen = $this->db->prepare("INSERT INTO nutzer (id, name, passwort_hash) VALUES (?, ?, ?)");
$anlegen->execute([1, 'mia', '$2y$12$WxNFz16zb7VpJN/UxpGAguqQhLyiThjRJZBJFeuJwFBr7VVdwuWN6']);
$anlegen->execute([2, 'tom', '$2y$12$hKxe1CW6owB0RCsJo1nB0OiUpjiI2iI1b4VojcmwMrqVzfl8KMSES']);
// Nummer 2 gehoert tom.
$this->db->exec("INSERT INTO lesezeichen (id, nutzer_id, titel, url) VALUES
(1, 1, 'Astro', 'https://astro.example'),
(2, 2, 'Toms Werkstatt', 'https://example.org/tom'),
(3, 1, 'PHP-Handbuch', 'https://php-handbuch.example/'),
(4, 1, 'Wikipedia', 'https://wikipedia.example'),
(5, 1, '50 % schneller', 'https://example.org/schneller')");
}
public function nutzerAnlegen(string $name, string $hash): void
{
$this->db->prepare("INSERT INTO nutzer (name, passwort_hash) VALUES (:n, :h)")
->execute([":n" => $name, ":h" => $hash]);
}
public function nutzerNachName(string $name): array|false
{
$abfrage = $this->db->prepare("SELECT id, name, passwort_hash FROM nutzer WHERE name = :n");
$abfrage->execute([":n" => $name]);
return $abfrage->fetch();
}
public function alleVon(int $nutzerId): array
{
$abfrage = $this->db->prepare("SELECT id, titel, url FROM lesezeichen WHERE nutzer_id = :b ORDER BY id DESC");
$abfrage->execute([":b" => $nutzerId]);
return $abfrage->fetchAll();
}
public function holen(int $id): array|false
{
$abfrage = $this->db->prepare("SELECT id, nutzer_id, titel, url FROM lesezeichen WHERE id = :id");
$abfrage->execute([":id" => $id]);
return $abfrage->fetch();
}
public function anlegen(int $nutzerId, string $titel, string $url): void
{
$this->db->prepare("INSERT INTO lesezeichen (nutzer_id, titel, url) VALUES (:b, :t, :u)")
->execute([":b" => $nutzerId, ":t" => $titel, ":u" => $url]);
}
public function aendern(int $id, string $titel, string $url): void
{
$this->db->prepare("UPDATE lesezeichen SET titel = :t, url = :u WHERE id = :id")
->execute([":t" => $titel, ":u" => $url, ":id" => $id]);
}
public function loeschen(int $id): void
{
$this->db->prepare("DELETE FROM lesezeichen WHERE id = :id")->execute([":id" => $id]);
}
private function fuerLike(string $begriff): string
{
// Der Backslash zuerst, sonst wuerden gleich darauf die
// Backslashes verdoppelt, die erst hier entstanden sind.
return str_replace(["\\", "%", "_"], ["\\\\", "\\%", "\\_"], $begriff);
}
public function suchen(int $nutzerId, string $begriff): array
{
$abfrage = $this->db->prepare(
"SELECT id, titel, url FROM lesezeichen
WHERE nutzer_id = :b AND titel LIKE :s ESCAPE '\\'
ORDER BY id DESC"
);
$abfrage->execute([":b" => $nutzerId, ":s" => "%" . $this->fuerLike($begriff) . "%"]);
return $abfrage->fetchAll();
}
} <?php
declare(strict_types=1);
use App\LesezeichenSpeicher;
// Alle Seitenfunktionen der Anwendung, die Luecken so gefuellt, wie der
// Kurs sie loest. Aus den Aufgaben stammen verlangeAnmeldung(),
// aktionRegistrieren() und aktionAnmelden() (18.3), istErlaubteAdresse()
// und aktionAnlegen() (18.4), verlangeEigenes(), pruefeToken() und
// seiteBearbeiten() (18.5). Der Rest war in jeder Aufgabe gegeben.
function verlangeEigenes(LesezeichenSpeicher $speicher, ?string $id): array
{
$nutzerId = verlangeAnmeldung();
$eintrag = $speicher->holen((int) $id);
if ($eintrag === false) {
beendeMit(404, "Dieses Lesezeichen gibt es nicht.");
}
if ((int) $eintrag["nutzer_id"] !== $nutzerId) {
beendeMit(403, "Das ist nicht dein Lesezeichen.");
}
return $eintrag;
}
function pruefeToken(): void
{
$erwartet = $_SESSION["token"] ?? "";
if ($erwartet === "" || !hash_equals($erwartet, $_POST["token"] ?? "")) {
beendeMit(403, "Kein gültiges Token.");
}
}
function seiteBearbeiten(LesezeichenSpeicher $speicher, ?string $id = null): string
{
return rendere(__DIR__ . "/../ansichten/bearbeiten.php", [
"eintrag" => verlangeEigenes($speicher, $id),
"token" => token(),
]);
}
// Die beiden Aktionen rufen erst pruefeToken() und dann verlangeEigenes()
// auf, die zwei Funktionen aus Lektion 18.5. Damit haengt der ganze
// Schutz an diesen beiden, und nicht daran, dass jemand ihn an drei Stellen
// richtig abschreibt.
function aktionSpeichern(LesezeichenSpeicher $speicher, ?string $id = null): string
{
pruefeToken();
$eintrag = verlangeEigenes($speicher, $id);
$titel = trim($_POST["titel"] ?? "");
$url = trim($_POST["url"] ?? "");
if ($titel === "" || !istErlaubteAdresse($url)) {
merkeMeldung("Das war nichts: Ein Titel muss da sein, und die Adresse muss mit http:// oder https:// anfangen.");
weiterZu("/lesezeichen/" . (int) $eintrag["id"] . "/bearbeiten");
}
$speicher->aendern((int) $eintrag["id"], $titel, $url);
merkeMeldung("Gespeichert.");
weiterZu("/liste");
}
function aktionLoeschen(LesezeichenSpeicher $speicher, ?string $id = null): string
{
pruefeToken();
$eintrag = verlangeEigenes($speicher, $id);
$speicher->loeschen((int) $eintrag["id"]);
merkeMeldung("Gelöscht.");
weiterZu("/liste");
}
function istErlaubteAdresse(string $url): bool
{
return str_starts_with($url, "http://") || str_starts_with($url, "https://");
}
function verlangeAnmeldung(): int
{
if (empty($_SESSION["nutzer_id"])) {
weiterZu("/login");
}
return (int) $_SESSION["nutzer_id"];
}
function seiteStart(LesezeichenSpeicher $speicher, ?string $id = null): string
{
weiterZu(empty($_SESSION["nutzer_id"]) ? "/login" : "/liste");
}
function seiteAnmelden(LesezeichenSpeicher $speicher, ?string $id = null): string
{
return rendere(__DIR__ . "/../ansichten/anmelden.php", ["fehler" => null]);
}
function aktionRegistrieren(LesezeichenSpeicher $speicher, ?string $id = null): string
{
$name = trim($_POST["name"] ?? "");
$passwort = $_POST["passwort"] ?? "";
if ($name === "" || mb_strlen($passwort) < 15) {
merkeMeldung("Ein Name und ein Passwort mit mindestens 15 Zeichen, bitte.");
weiterZu("/login");
}
if ($speicher->nutzerNachName($name) !== false) {
merkeMeldung("Diesen Namen gibt es schon.");
weiterZu("/login");
}
$speicher->nutzerAnlegen($name, password_hash($passwort, PASSWORD_DEFAULT, ["cost" => 12]));
$angelegt = $speicher->nutzerNachName($name);
session_regenerate_id(true);
$_SESSION["nutzer_id"] = (int) $angelegt["id"];
$_SESSION["name"] = $angelegt["name"];
weiterZu("/liste");
}
function aktionAnmelden(LesezeichenSpeicher $speicher, ?string $id = null): string
{
$nutzer = $speicher->nutzerNachName(trim($_POST["name"] ?? ""));
// Auch fuer einen unbekannten Namen rechnet password_verify, gegen
// einen Ersatz-Hash mit denselben Kosten. Sonst verriete die
// Antwortzeit, welche Namen vergeben sind.
$hash = $nutzer === false ? '$2y$12$5rTZVJchRm1u8TsgqFvqrOkv4n1NfL.aaBR6FkfZgzlbAh7bvc7TW' : $nutzer["passwort_hash"];
$passt = password_verify($_POST["passwort"] ?? "", $hash);
if ($nutzer === false || !$passt) {
merkeMeldung("Name oder Passwort stimmt nicht.");
weiterZu("/login");
}
// Eine frische Kennung, sobald die Sitzung etwas wert ist: Wer die alte
// kannte oder untergeschoben hat, hat danach nichts mehr davon.
session_regenerate_id(true);
$_SESSION["nutzer_id"] = (int) $nutzer["id"];
$_SESSION["name"] = $nutzer["name"];
weiterZu("/liste");
}
function aktionAbmelden(LesezeichenSpeicher $speicher, ?string $id = null): string
{
$_SESSION = [];
setcookie(session_name(), "", ["expires" => time() - 3600, "path" => "/"]);
session_destroy();
weiterZu("/login");
}
function seiteListe(LesezeichenSpeicher $speicher, ?string $id = null): string
{
$nutzerId = verlangeAnmeldung();
return rendere(__DIR__ . "/../ansichten/liste.php", [
"lesezeichen" => $speicher->alleVon($nutzerId),
"suchbegriff" => null,
"name" => $_SESSION["name"],
"token" => token(),
]);
}
function aktionAnlegen(LesezeichenSpeicher $speicher, ?string $id = null): string
{
pruefeToken();
$nutzerId = verlangeAnmeldung();
$titel = trim($_POST["titel"] ?? "");
$url = trim($_POST["url"] ?? "");
if ($titel === "" || !istErlaubteAdresse($url)) {
merkeMeldung("Das war nichts: Ein Titel muss da sein, und die Adresse muss mit http:// oder https:// anfangen.");
weiterZu("/liste");
}
$speicher->anlegen($nutzerId, $titel, $url);
weiterZu("/liste");
}
function seiteSuche(LesezeichenSpeicher $speicher, ?string $id = null): string
{
$nutzerId = verlangeAnmeldung();
$begriff = trim($_GET["q"] ?? "");
return rendere(__DIR__ . "/../ansichten/liste.php", [
"lesezeichen" => $begriff === "" ? [] : $speicher->suchen($nutzerId, $begriff),
"suchbegriff" => $begriff,
"name" => $_SESSION["name"],
"token" => token(),
]);
}
function seiteNichtGefunden(string $pfad): string
{
return "<p>Die Adresse " . e($pfad) . " kennt diese Anwendung nicht.</p>"
. '<p><a href="/liste">Zurück zur Liste</a></p>';
} <!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title><?= e($titel) ?></title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
li { margin-bottom: .4rem; }
.meldung { background: #eef; padding: .5rem .8rem; }
</style>
</head>
<body>
<h1><?= e($titel) ?></h1>
<?php if ($meldung !== null): ?>
<p class="meldung"><?= e($meldung) ?></p>
<?php endif; ?>
<?= $inhalt ?>
</body>
</html> <?php if ($fehler !== null): ?>
<p><strong><?= e($fehler) ?></strong></p>
<?php endif; ?>
<h2>Anmelden</h2>
<form method="post" action="/login">
<p><label>Name: <input type="text" name="name"></label></p>
<p><label>Passwort: <input type="password" name="passwort"></label></p>
<button type="submit">Anmelden</button>
</form>
<h2>Neu hier?</h2>
<form method="post" action="/registrieren">
<p><label>Name: <input type="text" name="name"></label></p>
<p><label>Passwort: <input type="password" name="passwort"></label></p>
<button type="submit">Konto anlegen</button>
</form> <p>Angemeldet als <?= e($name) ?> · <a href="/liste">Alle</a> · <a href="/logout">Abmelden</a></p>
<form method="get" action="/suche">
<p><input type="search" name="q" value="<?= e($suchbegriff) ?>" placeholder="Titel durchsuchen">
<button type="submit">Suchen</button></p>
</form>
<?php if ($suchbegriff === null): ?>
<form method="post" action="/lesezeichen">
<input type="hidden" name="token" value="<?= e($token) ?>">
<p><label>Titel: <input type="text" name="titel"></label></p>
<p><label>Adresse: <input type="text" name="url" size="34"></label></p>
<button type="submit">Hinzufügen</button>
</form>
<?php endif; ?>
<?php if ($lesezeichen === []): ?>
<p><?= $suchbegriff === null ? "Noch nichts gespeichert." : "Keine Treffer." ?></p>
<?php else: ?>
<ul>
<?php foreach ($lesezeichen as $eintrag): ?>
<li>
<a href="<?= e($eintrag["url"]) ?>"><?= e($eintrag["titel"]) ?></a>
<a href="/lesezeichen/<?= (int) $eintrag["id"] ?>/bearbeiten">bearbeiten</a>
<form method="post" action="/lesezeichen/<?= (int) $eintrag["id"] ?>/loeschen" style="display:inline">
<input type="hidden" name="token" value="<?= e($token) ?>">
<button type="submit">löschen</button>
</form>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?> <form method="post" action="/lesezeichen/<?= (int) $eintrag["id"] ?>/speichern">
<input type="hidden" name="token" value="<?= e($token) ?>">
<p><label>Titel: <input type="text" name="titel" value="<?= e($eintrag["titel"]) ?>"></label></p>
<p><label>Adresse: <input type="text" name="url" size="34" value="<?= e($eintrag["url"]) ?>"></label></p>
<button type="submit">Speichern</button>
</form>
<p><a href="/liste">Zurück zur Liste</a></p> <?php
declare(strict_types=1);
// Der Front Controller aus Lektion 16.7, und die einzige Datei im
// oeffentlichen Ordner. Jede Anfrage kommt hier an, und was eine
// Seitenfunktion zurueckgibt, steckt die letzte Zeile ins Layout.
use App\LesezeichenSpeicher;
session_start();
$konfig = require __DIR__ . "/../konfiguration.php";
require __DIR__ . "/../src/funktionen.php";
require __DIR__ . "/../src/LesezeichenSpeicher.php";
require __DIR__ . "/../src/seiten.php";
$routen = require __DIR__ . "/../src/routen.php";
$speicher = new LesezeichenSpeicher($konfig["datenbank"]);
$methode = $_SERVER["REQUEST_METHOD"];
$pfad = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH) ?: "";
$inhalt = null;
foreach ($routen as $route) {
if ($route[0] !== $methode) {
continue;
}
$werte = passtRoute($route[1], $pfad);
if ($werte !== null) {
$inhalt = $route[2]($speicher, $werte[0] ?? null);
break;
}
}
if ($inhalt === null) {
http_response_code(404);
$inhalt = seiteNichtGefunden($pfad);
}
echo rendere(__DIR__ . "/../ansichten/seite.php", [
"titel" => $konfig["titel"],
"inhalt" => $inhalt,
"meldung" => holeMeldung(),
]); Das erste Beispiel ist die Anwendung nach der Suche, zehn Dateien mit allen Lücken so gefüllt, wie
der Kurs sie löst. Im Reiter Browser melden sich mia mit gurkenbeet-im-mai und tom mit
zaun-streichen-heute an, wie in den Aufgaben.
Eine Suche nach php geht so durch die Dateien. oeffentlich/index.php startet die Sitzung, liest
konfiguration.php, holt die drei Dateien aus src/ herein und baut die Speicherklasse, die ihre
Tabellen anlegt, falls sie fehlen. Die Schleife über src/routen.php findet die Zeile für
GET /suche und ruft seiteSuche() aus src/seiten.php. Die fragt zuerst verlangeAnmeldung()
und gibt den Begriff dann an suchen() in der Speicherklasse weiter, der einzigen Datei mit SQL.
Mit den Treffern füllt rendere() die Vorlage ansichten/liste.php, und zuletzt legt index.php
das Layout aus ansichten/seite.php darum und gibt alles einmal aus.
Bis zu dieser letzten Zeile kommt nicht jede Anfrage. weiterZu() und beendeMit() enden mit
exit, und was sie schicken, eine Weiterleitung oder eine kurze Fehlerseite, bekommt nie ein
Layout. In der Befehlsleiste bringt die Suche <h1>Lesezeichen</h1> aus dem Layout mit, ein POST
ohne Token nur <h1>Kein gültiges Token.</h1> aus beendeMit().
Was davon von dir ist
Ohne Leerzeilen und Kommentare hat die Anwendung rund 420 Zeilen. Knapp hundert davon sind die Lücken aus deinen fünf Aufgaben, also die Tabellen und vier Methoden der Speicherklasse, die Wand vor der Liste mit den zwei Wegen hinein, die Allowlist für Adressen samt dem Anlegen, Besitzerprüfung und Tokenprüfung und zuletzt die Suche. Front Controller, Routentabelle, Vorlagen und Helfer standen jedes Mal fertig da. Jedes davon hatte seine Lektion in Abschnitt 16 oder davor, nur für diese Anwendung hast du keines geschrieben, auch keine Zeile HTML.
Die Tests der Speicherklasse
{
"name": "lesezeichen/projekt",
"require-dev": {
"phpunit/phpunit": "^11"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
} <?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="alle">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit> <?php
declare(strict_types=1);
// Startet PHPUnit. Der Button am Beispiel startet immer eine PHP-Datei,
// also uebernimmt diese Zeile den Aufruf. Im Terminal brauchst du sie
// nicht, dort tippst du vendor/bin/phpunit direkt, wie auf deinem Rechner.
require __DIR__ . "/vendor/bin/phpunit"; <?php
declare(strict_types=1);
namespace App;
use PDO;
// Die Klasse aus dem ersten Beispiel, mit zwei Unterschieden. Die
// Startdaten fehlen, denn ein Test legt sich seinen Zustand selbst hin,
// sonst prueft er die Startdaten halb mit. Und die Suche maskiert mit dem
// Ausrufezeichen statt mit dem Backslash; welches Zeichen es ist, sagt der
// Abfrage das ESCAPE.
class LesezeichenSpeicher
{
private PDO $db;
public function __construct(string $datei)
{
if (!is_dir(dirname($datei))) {
mkdir(dirname($datei), 0777, true);
}
$this->db = new PDO("sqlite:" . $datei, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->exec("CREATE TABLE IF NOT EXISTS nutzer (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
passwort_hash TEXT NOT NULL
)");
$this->db->exec("CREATE TABLE IF NOT EXISTS lesezeichen (
id INTEGER PRIMARY KEY,
nutzer_id INTEGER NOT NULL REFERENCES nutzer(id),
titel TEXT NOT NULL,
url TEXT NOT NULL
)");
}
public function nutzerAnlegen(string $name, string $hash): void
{
$this->db->prepare("INSERT INTO nutzer (name, passwort_hash) VALUES (:n, :h)")
->execute([":n" => $name, ":h" => $hash]);
}
public function nutzerNachName(string $name): array|false
{
$abfrage = $this->db->prepare("SELECT id, name, passwort_hash FROM nutzer WHERE name = :n");
$abfrage->execute([":n" => $name]);
return $abfrage->fetch();
}
public function alleVon(int $nutzerId): array
{
$abfrage = $this->db->prepare("SELECT id, titel, url FROM lesezeichen WHERE nutzer_id = :b ORDER BY id DESC");
$abfrage->execute([":b" => $nutzerId]);
return $abfrage->fetchAll();
}
public function holen(int $id): array|false
{
$abfrage = $this->db->prepare("SELECT id, nutzer_id, titel, url FROM lesezeichen WHERE id = :id");
$abfrage->execute([":id" => $id]);
return $abfrage->fetch();
}
public function anlegen(int $nutzerId, string $titel, string $url): void
{
$this->db->prepare("INSERT INTO lesezeichen (nutzer_id, titel, url) VALUES (:b, :t, :u)")
->execute([":b" => $nutzerId, ":t" => $titel, ":u" => $url]);
}
public function aendern(int $id, string $titel, string $url): void
{
$this->db->prepare("UPDATE lesezeichen SET titel = :t, url = :u WHERE id = :id")
->execute([":t" => $titel, ":u" => $url, ":id" => $id]);
}
public function loeschen(int $id): void
{
$this->db->prepare("DELETE FROM lesezeichen WHERE id = :id")->execute([":id" => $id]);
}
private function fuerLike(string $begriff): string
{
// Das Ausrufezeichen zuerst, sonst wuerden gleich darauf die
// Ausrufezeichen verdoppelt, die erst hier entstanden sind.
return str_replace(["!", "%", "_"], ["!!", "!%", "!_"], $begriff);
}
public function suchen(int $nutzerId, string $begriff): array
{
$abfrage = $this->db->prepare(
"SELECT id, titel, url FROM lesezeichen
WHERE nutzer_id = :b AND titel LIKE :s ESCAPE '!'
ORDER BY id DESC"
);
$abfrage->execute([":b" => $nutzerId, ":s" => "%" . $this->fuerLike($begriff) . "%"]);
return $abfrage->fetchAll();
}
} <?php
declare(strict_types=1);
use App\LesezeichenSpeicher;
use PHPUnit\Framework\TestCase;
final class LesezeichenSpeicherTest extends TestCase
{
private LesezeichenSpeicher $speicher;
// Vor jedem Test eine frische Datenbank, und zwar im Arbeitsspeicher.
// Deshalb nimmt der Konstruktor den Pfad entgegen, statt ihn zu
// kennen: Was hereingegeben wird, kann ein Test bestimmen. Das ist die
// Regel aus Lektion 17.4, hier an der eigenen Klasse.
protected function setUp(): void
{
$this->speicher = new LesezeichenSpeicher(":memory:");
$this->speicher->nutzerAnlegen("mia", "egal");
$this->speicher->nutzerAnlegen("tom", "egal");
}
public function testJederSiehtNurSeineEigenen(): void
{
$this->speicher->anlegen(1, "Astro", "https://astro.example");
$this->speicher->anlegen(2, "Toms Werkstatt", "https://example.org/tom");
$this->assertCount(1, $this->speicher->alleVon(1));
$this->assertSame("Astro", $this->speicher->alleVon(1)[0]["titel"]);
}
public function testDasNeuesteStehtOben(): void
{
$this->speicher->anlegen(1, "Zuerst", "https://example.org/1");
$this->speicher->anlegen(1, "Danach", "https://example.org/2");
$this->assertSame("Danach", $this->speicher->alleVon(1)[0]["titel"]);
}
public function testDieSucheFindetEinenTeilDesTitels(): void
{
$this->speicher->anlegen(1, "PHP-Handbuch", "https://php-handbuch.example/");
$this->assertCount(1, $this->speicher->suchen(1, "handbuch"));
$this->assertCount(0, $this->speicher->suchen(1, "javascript"));
}
public function testEinProzentzeichenIstEinZeichenUndKeinMuster(): void
{
$this->speicher->anlegen(1, "PHP-Handbuch", "https://php-handbuch.example/");
$this->assertCount(0, $this->speicher->suchen(1, "%"));
}
public function testGeloeschtIstGeloescht(): void
{
$this->speicher->anlegen(1, "Weg damit", "https://example.org/weg");
$id = $this->speicher->alleVon(1)[0]["id"];
$this->speicher->loeschen((int) $id);
$this->assertSame([], $this->speicher->alleVon(1));
$this->assertFalse($this->speicher->holen((int) $id));
}
} Am meisten bringen Tests an der Speicherklasse. Sie gibt nichts aus, nimmt ihre Zutaten entgegen,
und in ihr stehen die Regeln, bei denen ein Fehler still bliebe. Das zweite Beispiel hält fünf davon
fest und läuft im Terminal wie in Abschnitt 17 mit vendor/bin/phpunit. Möglich ist das, weil der
Konstruktor den Pfad zur Datenbank entgegennimmt, und setUp() gibt ihm :memory:. Vor einer
Änderung an der Klasse läuft die Suite einmal und danach gleich noch einmal.
Die Seitenfunktionen fehlen in der Suite, denn sie lesen $_SESSION, schicken Kopfzeilen und enden
mit exit.
Vier Aufträge an dieser Anwendung
Jeder geht mit Werkzeugen aus dem Kurs und berührt dieselben Dateien wie die fünf Aufgaben.
- Nach Titel sortieren, über einen Link wie
/liste?sortierung=titel. Für einen Spaltennamen gibt es keinen Platzhalter, hier hilft die Allowlist aus dem Abschnitt über Sicherheit, am einfachsten alsmatchaus Abschnitt 4. - Seiten zu je zehn Einträgen.
LIMITkommt aus Abschnitt 14,OFFSETüberspringt die Zeilen davor, und beide nehmen Platzhalter, solange ganze Zahlen hineingehen. Die Seitennummer kommt aus der Adresse und wird mit(int)eine Zahl. - Die eigenen Lesezeichen als JSON unter
/export, mitjson_encode()aus Abschnitt 8 undContent-Type: application/jsonaus Abschnitt 10. Der Front Controller legt allerdings um jede Rückgabe das Layout. Das JSON stünde dann mitten in einer HTML-Seite, und ein Titel mit Markup liefe ungeschützt mit, dennjson_encode()maskiert kein HTML. Die Exportfunktion gibt deshalb selbst aus und endet mitexit. - Eine Notiz zu jedem Lesezeichen, als drittes Feld neben Titel und Adresse. Formular und Ausgabe gehen wie in Abschnitt 11, neu ist nur die Spalte.
Der erste Schritt zur Notiz
<?php
declare(strict_types=1);
// Der erste Schritt zur Notiz je Lesezeichen: die Spalte. Die Speicherklasse
// legt ihre Tabellen bei jedem Start an, also laeuft auch alles, was hier
// dazukommt, bei jedem Start, und zwar auf einer Datenbank, die schon Zeilen hat.
function verbinde(): PDO
{
$db = new PDO("sqlite::memory:", null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// So steht die Tabelle heute da, mit einem Lesezeichen darin.
$db->exec("CREATE TABLE lesezeichen (
id INTEGER PRIMARY KEY,
nutzer_id INTEGER NOT NULL REFERENCES nutzer(id),
titel TEXT NOT NULL,
url TEXT NOT NULL
)");
$db->exec("INSERT INTO lesezeichen (nutzer_id, titel, url) VALUES (1, 'Astro', 'https://astro.example')");
return $db;
}
function spalten(PDO $db): array
{
return array_column($db->query("PRAGMA table_info(lesezeichen)")->fetchAll(), "name");
}
function ergaenzeEinfach(PDO $db): void
{
$db->exec("ALTER TABLE lesezeichen ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
}
function ergaenzeMitNachsehen(PDO $db): void
{
if (!in_array("notiz", spalten($db), true)) {
$db->exec("ALTER TABLE lesezeichen ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
}
}
$db = verbinde();
foreach ([1, 2] as $start) {
try {
ergaenzeEinfach($db);
echo "Start ", $start, " einfach: ", implode(", ", spalten($db)), "\n";
} catch (PDOException $fehler) {
echo "Start ", $start, " einfach: ", $fehler->getMessage(), "\n";
}
}
$db = verbinde();
foreach ([1, 2] as $start) {
ergaenzeMitNachsehen($db);
echo "Start ", $start, " mit Nachsehen: ", implode(", ", spalten($db)), "\n";
}
$astro = $db->query("SELECT titel, notiz FROM lesezeichen WHERE id = 1")->fetch();
echo "\nDas alte Lesezeichen: ", $astro["titel"], ", Notiz: \"", $astro["notiz"], "\"\n"; Die Spalte kommt mit ALTER TABLE dazu, wie in der Lektion über die Datenbasis. Weil sich die
Speicherklasse bei jedem Start einrichtet, liefe das ALTER TABLE aber auch bei jedem Start. Beim
ersten Mal kommt notiz dazu, beim zweiten meldet SQLite duplicate column name: notiz, und in der
Anwendung stünde ab der zweiten Anfrage dieser Fatal error statt einer Seite. Die zweite Fassung im
Beispiel sieht mit PRAGMA table_info nach und legt die Spalte nur an, wenn sie fehlt. Ein
Lesezeichen, das schon da war, bekommt den Standardwert, einen leeren Text.
Zu Hause weiterbauen
Auf dem eigenen Rechner bleibt die Datenbank von einem Tag zum nächsten liegen. Wie PHP und Composer
dorthin kommen, beschreibt die Lektion „PHP und Composer auf deinem Rechner“ in Abschnitt 9. Die
zehn Dateien aus dem ersten Beispiel kommen mit denselben Unterordnern in einen Ordner, und
php -d display_errors=1 -S 127.0.0.1:3000 -t oeffentlich startet die Anwendung darin, im Browser
unter http://127.0.0.1:3000.
Die Startdaten braucht zu Hause niemand. Ohne den Aufruf von startdaten() im Konstruktor fängt die
Datenbank leer an, und das erste Konto entsteht über die Anmeldeseite. Erst dann bestehen auch die
fünf Tests an dieser Klasse, mit dem Aufruf scheitern alle schon in setUp(). Dafür kommen
phpunit.xml, tests/ und die composer.json aus dem zweiten Beispiel dazu, und
composer install holt PHPUnit.
Was danach kommt
Wächst die Anwendung, mit mehr Seiten, mehr Regeln und mehr Leuten, lohnt sich ein Framework wie
Laravel oder Symfony. Es bringt fertig mit, was hier in src/funktionen.php, src/routen.php und
oeffentlich/index.php steht, und kennt tausend Fälle mehr.
Dazu kommt mehr SQL. Ein Index auf nutzer_id lässt SQLite gleich die eigenen Zeilen finden, statt
die ganze Tabelle zu lesen, einem LIKE mit % vorn hilft ein Index auf titel dagegen nicht. Mit
MySQL oder PostgreSQL ändert sich die Verbindung im Konstruktor, und ein paar Abfragen ziehen mit
um, denn PostgreSQL unterscheidet bei LIKE Groß und Klein, und MySQL liest '\' als Text, der
noch nicht zu Ende ist.
Wie die Anwendung samt Zugangsdaten, Sicherungen und HTTPS auf einen Server kommt, ist ein Thema für sich. Bewusst offen bleiben auch E-Mail, Dateiuploads über die Lektion zum Hochladen hinaus, mehrsprachige Oberflächen, Rechte über „gehört dir“ hinaus und alles mit mehreren Servern.
Zum Mitnehmen
Jede Anfrage geht denselben Weg, vom Front Controller über Routentabelle und Seitenfunktion bis zur Vorlage, und nur Weiterleitungen und die kurzen Fehlerseiten enden vorher. Von den rund 420 Zeilen der Anwendung stammen knapp hundert aus den fünf Aufgaben. Die vier Aufträge gehen mit dem, was der Kurs gezeigt hat, und weitergebaut wird am besten auf dem eigenen Rechner.
Jetzt du
Basis Konto, kostenlosIm Editor änderst du die Beispiele dieser Lektion und lässt sie gleich laufen. So merkst du am schnellsten, ob es sitzt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.