Statistik: Zähler statt Protokoll, deshalb ohne Einwilligung
"Wieviel wann wo wie die Seite benutzt" - naheliegend wäre ein Analysewerkzeug gewesen, das eine Einwilligung braucht und damit genau die Besucher verliert, die ablehnen. Also andersherum: eine Messung, die niemanden wiedererkennt. Gespeichert wird ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain), kein Datensatz je Aufruf. Keine IP-Adresse, keine Kennung, kein Cookie - auch nicht gehasht, denn der Hash einer IP-Adresse bleibt ein Personenbezug. Ein Test liest die Spaltenliste der Tabelle und vergleicht sie mit dieser Zusage, damit sie beim nächsten Ausbau nicht unbemerkt fällt. Vom Verweis bleibt nur der Host: die volle Adresse kann einen Suchbegriff oder einen Namen tragen. Gezählt wird auf kernel.terminate, nach der Antwort - die Messung darf keine Anfrage verlangsamen, und ein Fehler beim Zählen kostet einen Zähler, nicht eine Seite. Nur GET auf HTML mit 200 oder 404; Verwaltung, Assets, Feed, Sitemap und Einwilligungsseite sind ausgenommen, erkennbare Maschinen über ihre Kennung. Das ist eine Heuristik, deshalb heißt die Zahl auf dem Bildschirm nicht "Besucher". Erfundene Crawler-Adressen laufen gesammelt unter (sonstige), sonst wächst die Tabelle mit jedem geprobten Pfad. Die Diagramme zeichnet der Server als SVG mit Klassen statt Stilattributen: kein Diagrammwerkzeug, kein Skript, und die Richtlinie bleibt streng. Die Zahlen stehen zusätzlich in einer Tabelle, weil ein Balken für einen Screenreader nichts ist. Was die Zahlen nicht sagen, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist die Folge der Entscheidung, nicht eine Lücke. Aufbewahrung: nach 60 Tagen werden die Stunden eines Tages verschmolzen, nach 425 Tagen gelöscht - gut ein Jahr, damit der Vorjahresmonat vergleichbar bleibt. cms:stats:prune macht beides und gehört in einen Cronjob. 279 PHP-Tests / 1119 Assertions unter PHP 8.2.12 und 8.4.22, axe auf zwölf Verwaltungsbildschirmen ohne Verstöße. Dabei kam eine PHP-8.4-Verwarnung zu fputcsv() heraus, jetzt mit ausdrücklichem Escape-Parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee446f1ea1
commit
99bad61179
18 files changed
+858
-7
No files matched your search
@@ -109,4 +109,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche
|
||||
|
||||
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` gehört danach in einen täglichen Cronjob. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
|
||||
@@ -419,3 +419,23 @@ Umut: „datenschutz und Impressum einbauen sind keine option“. Richtig – in
|
||||
- Beide werden **von jeder Seite verlinkt**, aus dem Template und nicht aus dem Fußbereich. Der Fußbereich ist ein frei bearbeitbares Blockdokument; ein Pflichtlink, den man versehentlich löschen kann, ist kein Pflichtlink. Die Links liegen mit dem Link „Einwilligung ändern“ in einem benannten Landmark – loser Text außerhalb aller Landmarks ist auch das, was axe zu Recht beanstandet.
|
||||
|
||||
Eine Pfadangabe wird beim Speichern geprüft: zeigt sie auf eine Seite, die es nicht gibt, wird sie abgewiesen.
|
||||
|
||||
## ADR 034 – Statistik: Zähler statt Protokoll (12.09.2026)
|
||||
|
||||
Umut wollte wissen, „wieviel wann wo wie die Seite benutzt“ wird. Die naheliegende Lösung – ein Analysewerkzeug einbinden – hätte eine Einwilligung gebraucht, und damit hätte die Messung genau die Besucher verloren, die ablehnen. Also andersherum gebaut: eine Messung, die niemanden wiedererkennt und deshalb keine Einwilligung braucht.
|
||||
|
||||
**Gespeichert wird ein Zähler, kein Datensatz je Aufruf.** Eine Zeile je `(Tag, Stunde, Pfad, Status, Verweis-Domain)`, die hochgezählt wird. Es gibt keine Spalte, aus der eine Person hervorgeht: keine IP-Adresse, keine Kennung, kein Cookie – **auch nicht gehasht**, denn der Hash einer IP-Adresse bleibt ein Personenbezug. Ein Test prüft die Spaltenliste der Tabelle gegen genau diese Zusage, damit sie beim nächsten Ausbau nicht unbemerkt verletzt wird.
|
||||
|
||||
Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam, kann einen Suchbegriff, einen Namen oder ein Token tragen – die wird nicht gespeichert.
|
||||
|
||||
**Gezählt wird nach der Antwort**, auf `kernel.terminate`: die Messung darf keine Anfrage verlangsamen, und ein Fehler beim Zählen darf keine Seite zerstören. Ein Ausfall kostet einen Zähler, nicht eine Seite. Gezählt werden nur GET-Anfragen auf HTML mit Status 200 oder 404; Verwaltung, Assets, Feed, Sitemap und die Einwilligungsseite sind ausgenommen.
|
||||
|
||||
**Erfundene Adressen laufen gesammelt.** Ein 404 behält seine Adresse nur, wenn sie aussieht wie eine Adresse dieser Website. Crawler probieren tausende Pfade wie `/wp-admin/setup-config.php`; jeder würde sonst eine eigene Zeile bekommen. Alles andere wird zu `(sonstige)`.
|
||||
|
||||
**Maschinen werden über die Kennung ausgeschlossen, und das ist eine Heuristik.** Wer sich als Browser ausgibt, wird mitgezählt. Die Zahl heißt deshalb „Aufrufe, die sich nicht als Maschine gemeldet haben“ – so steht es auch auf dem Bildschirm, weil eine Zahl mit falschem Namen schlimmer ist als eine fehlende.
|
||||
|
||||
**Die Diagramme zeichnet der Server** als SVG mit Klassen statt Stilattributen. Kein Diagrammwerkzeug, kein Skript, und die Richtlinie bleibt unangetastet – die Verwaltung verbietet Inline-Stile, ein Test prüft, dass im SVG keines steht. Die Zahlen selbst stehen zusätzlich in einer Tabelle, denn ein Balken ist für einen Screenreader nichts.
|
||||
|
||||
**Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein.
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
+2
-2
@@ -157,7 +157,7 @@ Anbieter und Tarif festlegen. MySQL oder MariaDB tatsächlich nachweisen statt n
|
||||
|
||||
Abnahme: Die Anwendung läuft auf dem Zielhosting mit MySQL. Messungen mit realen Inhalten liegen vor, einschließlich der Bedingungen.
|
||||
|
||||
### L – Statistik (Nutzerauftrag vom 12.09.2026)
|
||||
### L – Statistik (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 034)
|
||||
|
||||
*Braucht: nichts.*
|
||||
|
||||
@@ -170,7 +170,7 @@ Umut: „Als todo noch statistik kannst du das einbauen. Wieviel wann wo wie die
|
||||
**Was das beantwortet:** wie viele Aufrufe wann, welche Seiten, woher verlinkt, welche Adressen 404 liefern, wie sich das über Wochen entwickelt.
|
||||
**Was das nicht beantwortet, und zwar nie:** Besucher gegen Aufrufe (ohne Wiedererkennung nicht unterscheidbar), Verweildauer, Absprungrate, Klickpfade einzelner Personen, Land oder Stadt. Wer das braucht, braucht Wiedererkennung, und damit eine Einwilligung – dann wäre es ein eigener Baustein in der Gruppe `statistics`, die dafür schon existiert.
|
||||
|
||||
**Umsetzung:** ein Abonnent, der nach der Antwort zählt (nur `text/html`, nur Status 200/404, Verwaltung und Assets ausgenommen, Bots per User-Agent-Muster verworfen), ein Bildschirm „Statistik“ mit Tages- und Wochensicht als serverseitig gezeichnetes SVG-Balkendiagramm ohne JavaScript, Aufbewahrung 14 Monate mit Verdichtung auf Tageswerte nach 60 Tagen, und ein Export als CSV. Abnahme: ein Aufruf erhöht genau einen Zähler; ein Aufruf der Verwaltung erhöht keinen; die Tabelle enthält nach 1000 Aufrufen keine Spalte, aus der eine Person hervorgeht.
|
||||
**Umsetzung:** ein Abonnent, der nach der Antwort zählt (nur `text/html`, nur Status 200/404, Verwaltung und Assets ausgenommen, Bots per User-Agent-Muster verworfen), ein Bildschirm „Statistik“ mit Tages- und Wochensicht als serverseitig gezeichnetes SVG-Balkendiagramm ohne JavaScript, Aufbewahrung 14 Monate mit Verdichtung auf Tageswerte nach 60 Tagen, und ein Export als CSV. Abnahme: ein Aufruf erhöht genau einen Zähler; ein Aufruf der Verwaltung erhöht keinen; die Tabelle enthält nach 1000 Aufrufen keine Spalte, aus der eine Person hervorgeht. **Alle drei erfüllt.** Umgesetzt ist zusätzlich der CSV-Export und `cms:stats:prune` für Verdichtung und Löschung. Abweichung vom Plan: die Gerätegrößen-Klasse entfällt, weil sie ohne Skript nicht feststellbar ist – das stand schon im Plan und ist so geblieben.
|
||||
|
||||
### M – Inhalte und Ladeverhalten prüfen (Nutzerauftrag vom 12.09.2026)
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Statistik, 12.09.2026
|
||||
|
||||
- **279 PHP-Tests / 1119 Assertions** unter PHP 8.2.12 und PHP 8.4.22, Playwright-Editortest (axe auf jetzt zwölf Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
||||
- Der wichtigste Test ist der langweiligste: er liest die Spaltenliste von `stat_hit` und vergleicht sie mit der Zusage. Es gibt keine Spalte für IP-Adresse, Kennung, Sitzung, Cookie oder Hash – und wenn jemand später eine hinzufügt, bricht dieser Test, nicht erst eine Datenschutzprüfung.
|
||||
- 14 Anfragearten sind abgedeckt: Verwaltung, Bild, Stylesheet, Feed, Einwilligungsseite, Weiterleitung, Formularabsendung, Googlebot, Überwachungsdienst, Headless-Chrome und curl werden nicht gezählt; eine Seite und ein 404 werden gezählt, auch ohne Kennung.
|
||||
- Durch echte Anfragen über den Kernel geprüft: fünf Aufrufe derselben Seite in derselben Stunde werden **ein** Zähler mit dem Wert 5. Dafür musste der Testaufruf `kernel.terminate` auslösen – ohne das hätte der Test eine andere Anwendung geprüft als die, die läuft.
|
||||
- Der Bildschirm ist im Browser mit echten Zahlen angesehen worden (sechs Aufrufe, ein Verweis von Google, ein 404), und der CSV-Export ist im Test abgeholt und auf seinen Inhalt geprüft.
|
||||
- **PHP 8.4 hat dabei eine Verwarnung ausgesprochen**, die der Testlauf sichtbar machte: `fputcsv()` ohne ausdrücklichen Escape-Parameter. Jetzt ausdrücklich leer, was ein CSV-Leser erwartet.
|
||||
- **Nicht geprüft:** kein Screenreader. Die Aufbewahrung ist mit gestellten Daten geprüft, nicht über echte 425 Tage. Ob die Maschinenerkennung in der Praxis trifft, lässt sich erst am Piloten sehen – deshalb heißt die Zahl auf dem Bildschirm nicht „Besucher“.
|
||||
|
||||
## Ergänzung: Einwilligung, Pflichtseiten und ein Layoutfehler im Kopfbereich, 12.09.2026
|
||||
|
||||
- **245 PHP-Tests / 976 Assertions** unter PHP 8.2.12 und PHP 8.4.22, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,6 +25,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Statistik** (ADR 034, Arbeitspaket L): Aufrufe je Tag und Stunde, meistaufgerufene Seiten, Verweise von außen und nicht gefundene Adressen, als CSV exportierbar. Gespeichert wird **ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain)**, kein Datensatz je Aufruf – keine IP-Adresse, keine Kennung, kein Cookie, auch nicht gehasht. Deshalb braucht diese Messung keine Einwilligung. Diagramme zeichnet der Server als SVG, ohne Skript. Was sie nicht kann, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Nach 60 Tagen werden Stunden zu Tageswerten verschmolzen, nach 425 Tagen gelöscht (`cms:stats:prune`).
|
||||
- **Einwilligung** (ADR 032, Dokumentschema v14): serverseitig gerendertes Banner ohne JavaScript, „Alle akzeptieren“ und „Nur notwendige“ gleich groß nebeneinander, Auswahl je Gruppe im Aufklappfeld. Die Entscheidung steht in einem eigenen Cookie – keine Kennung, kein Serverabgleich, ein halbes Jahr. Unter `/einwilligung` jederzeit änderbar, von jeder Seite verlinkt. **Der neue Block „Externer Inhalt“ lädt ohne Einwilligung nichts:** kein iframe, keine Adresse im Markup, `frame-src 'none'`. Der Platzhalter nennt den Anbieter und gewährt auf Knopfdruck genau diese Gruppe. Ist der Hinweis abgeschaltet, laden die Inhalte – sonst würde das Abschalten jede Seite stillschweigend leeren.
|
||||
- **Datenschutz und Impressum sind Pflicht, nicht Einstellung** (ADR 033): fehlen sie, warnt die Verwaltung und legt sie auf Knopfdruck als Gerüst mit `NOCH AUSZUFÜLLEN`-Markierungen an. Verlinkt werden sie aus dem Template, nicht aus dem Fußbereich, damit der Link nicht versehentlich gelöscht werden kann.
|
||||
- **Kopf- und Fußbereich lagen falsch im Layout.** Umut hat es im Screenshot gesehen: die Wortmarke stand als Buchstabenkolonne in einem 60 Pixel breiten Kasten. Zwei Ursachen, beide behoben – `.site-header` war noch ein Flexcontainer für das alte, handgeschriebene Markup und quetschte die Sektion darin zusammen, und `design.css` band alle Designklassen an `#main`, sodass Kopf- und Fußbereich außerhalb davon jede Designeinstellung ignorierten.
|
||||
@@ -71,6 +72,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
||||
- Statistik geprüft: 32 Tests, darunter die Spaltenliste der Tabelle gegen die Zusage „keine Spalte, aus der eine Person hervorgeht“, 14 Anfragearten (Verwaltung, Bild, Stylesheet, Feed, Weiterleitung, POST, Googlebot, Überwachungsdienst, Headless, curl – alle nicht gezählt), sieben Verweisformen (nur der Host bleibt, die eigene Domain gilt nicht als Verweis), sechs Adressformen (Query verworfen, erfundene 404 gesammelt) und das Verschmelzen und Löschen alter Tage. Dazu durch echte Anfragen: fünf Aufrufe werden ein Zähler, ein 404 zählt nicht als Aufruf, Verwaltung und Feed zählen nicht.
|
||||
- Einwilligung geprüft: 26 PHP-Tests, darunter sieben Cookiezustände (gewährt, abgelehnt, andere Gruppe, abgelaufen, ältere Fassung der Frage, Unsinn, unbekannte Gruppe) und zehn abgewiesene Einbettungsadressen (http, protokollrelativ, `javascript:`, `data:`, mit Port, mit Zugangsdaten, Leerzeichen im Host, ohne Titel, unbekannte Gruppe, zu geringe Höhe). Im Browsertest zusätzlich: Banner erscheint, kein iframe und keine Adresse im Markup, Einwilligen über den Platzhalter lädt den Inhalt, Widerruf hält ihn wieder zurück, axe ohne Verstöße auf Banner und Einwilligungsseite.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf **elf** Bildschirmen – Anmeldung, Seitenübersicht, Benutzer, eigenes Konto, Mediathek, Menüs, Formulare, Bereiche, Design, Einstellungen und Editor in Desktop und Mobil. Vorher waren es drei.
|
||||
- Benutzerverwaltung geprüft: 16 Tests, darunter zwei gleichzeitige Sitzungen, die zeigen, dass ein zurückgesetztes Passwort und eine geänderte Rolle die andere Sitzung abmelden, während die handelnde angemeldet bleibt. Acht abgewiesene Kontoanlagen (zu kurzes Passwort, mehr als 72 Bytes, Nullbyte, drei ungültige Namen, Doppelname, unbekannte Rolle) legen kein Konto an. Jeder Schreibweg braucht ein CSRF-Token und ist ohne Administratorrolle 403.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260912001200 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Counters for the site statistics. No record per visit, no identifier, no IP address.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// A counter table, not a log: one row per (day, hour, path, status, referrer host) that
|
||||
// gets incremented. There is deliberately no column from which a person could follow -
|
||||
// no IP address, no identifier, not even a hashed one, because a hashed IP address is
|
||||
// still personal data. That is what makes this measurement possible without consent.
|
||||
//
|
||||
// The table is created with its indexes in one go, which is safe: the rule about adding
|
||||
// indexes only in postUp() exists because altering an existing table rebuilds it on
|
||||
// SQLite, and this table does not exist yet.
|
||||
$hits = $schema->createTable('stat_hit');
|
||||
$hits->addColumn('id', 'integer', ['autoincrement' => true]);
|
||||
$hits->addColumn('website_id', 'string', ['length' => 64]);
|
||||
$hits->addColumn('day', 'string', ['length' => 10]);
|
||||
// 0 to 23, or -1 for a day that has been compacted from hours into one row.
|
||||
$hits->addColumn('hour', 'smallint');
|
||||
$hits->addColumn('path', 'string', ['length' => 200]);
|
||||
$hits->addColumn('status', 'smallint');
|
||||
// Empty for a direct visit or a link from this site itself; a bare host otherwise.
|
||||
$hits->addColumn('referrer_host', 'string', ['length' => 100, 'default' => '']);
|
||||
$hits->addColumn('count', 'integer', ['default' => 0]);
|
||||
$hits->setPrimaryKey(['id']);
|
||||
// The counter is found by this tuple, so it has to be unique - otherwise two requests in
|
||||
// the same second would create two rows instead of incrementing one.
|
||||
$hits->addUniqueIndex(['website_id', 'day', 'hour', 'path', 'status', 'referrer_host'], 'uniq_stat_hit');
|
||||
$hits->addIndex(['website_id', 'day'], 'idx_stat_hit_day');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Dropping it loses the measurement and nothing else: no content, no account, no media.
|
||||
$schema->dropTable('stat_hit');
|
||||
}
|
||||
}
|
||||
@@ -551,3 +551,30 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-set { border: 1px solid var(--line); border-radius: 10px; padding: 4px 16px 16px; margin: 18px 0 0 }
|
||||
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
|
||||
.field-set .check-label { margin-top: 14px }
|
||||
|
||||
/* ---- statistics: charts drawn by the server, styled by class ---------------------------- */
|
||||
|
||||
.range-switch { display: flex; gap: 8px; flex-wrap: wrap }
|
||||
.range-switch a { font-size: 12px; padding: 8px 14px; min-height: 36px }
|
||||
.chart { display: block; width: 100%; height: 180px; overflow: visible }
|
||||
.chart-bar { fill: var(--accent) }
|
||||
.chart-bar:hover { fill: #1c4735 }
|
||||
.chart-axis { stroke: var(--line); stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-table { margin-top: 18px }
|
||||
.chart-table > summary { cursor: pointer; font-size: 12px; font-weight: 600; padding: 6px 0 }
|
||||
.table-wrap { max-height: 320px; overflow: auto; margin-top: 10px }
|
||||
.stats-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px }
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px; counter-reset: rank }
|
||||
.stats-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid #eef1ed;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats-list li:first-child { border-top: 0 }
|
||||
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
||||
@@ -354,6 +354,7 @@ try {
|
||||
['media library', '/admin/media'],
|
||||
['menus', '/admin/menus'],
|
||||
['forms', '/admin/forms'],
|
||||
['statistics', '/admin/statistik'],
|
||||
['areas', '/admin/partials'],
|
||||
['design', '/admin/theme'],
|
||||
['settings', '/admin/settings'],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\Statistics;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'cms:stats:prune',
|
||||
description: 'Merges the hours of older days into one row each and removes counters beyond the retention.',
|
||||
)]
|
||||
final class PruneStatisticsCommand extends Command
|
||||
{
|
||||
public function __construct(private Statistics $statistics)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$result = $this->statistics->prune();
|
||||
(new SymfonyStyle($input, $output))->success(sprintf(
|
||||
'%d Tag(e) auf Tageswerte zusammengefasst, %d Zeile(n) jenseits von %d Tagen entfernt.',
|
||||
$result['compacted'],
|
||||
$result['removed'],
|
||||
Statistics::KEEP_DAYS,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
|
||||
/**
|
||||
* How often, when and where the website was used.
|
||||
*
|
||||
* What makes this measurement possible **without asking for consent** is what it does not do: it
|
||||
* recognises nobody. There is no cookie, no identifier, no IP address, not even a hashed one - a
|
||||
* hash of an IP address is still personal data. Stored is a counter per (day, hour, path, status,
|
||||
* referrer host), incremented per request. The table therefore grows with the number of pages and
|
||||
* hours, not with the number of visits.
|
||||
*
|
||||
* The price of that is honest and worth saying out loud: this cannot tell visitors from views, it
|
||||
* has no session length, no bounce rate, no path through the site and no country. Anything of that
|
||||
* kind needs recognition, and recognition needs consent - it would be a separate component in the
|
||||
* `statistics` group, not this one.
|
||||
*/
|
||||
final class Statistics
|
||||
{
|
||||
/** Everything that is not a page of this site, or not a visit at all. */
|
||||
private const IGNORED = '#^/(admin|media/|assets/|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$|favicon)#';
|
||||
|
||||
/**
|
||||
* A heuristic, and nothing more: user agents lie. It keeps the obvious crawlers and monitors
|
||||
* out of the numbers, and the numbers are therefore "requests that did not announce
|
||||
* themselves as a machine", not "humans".
|
||||
*/
|
||||
private const MACHINES = '#bot|crawl|spider|slurp|bingpreview|headless|lighthouse|curl/|wget|python-requests|okhttp|monitor|uptime|preview#i';
|
||||
|
||||
/** A probed address that no page answers is counted, but not with its invented path. */
|
||||
public const OTHER = '(sonstige)';
|
||||
|
||||
/** Kept for a little over a year, so a comparison with the same month last year still works. */
|
||||
public const KEEP_DAYS = 425;
|
||||
|
||||
/** After two months the hours of a day are merged into one row. */
|
||||
public const COMPACT_AFTER_DAYS = 60;
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
/** Whether this request is one of the site's own pages and not an announced machine. */
|
||||
public function countable(string $method, string $path, int $status, string $contentType, string $userAgent): bool
|
||||
{
|
||||
return 'GET' === $method
|
||||
&& in_array($status, [200, 404], true)
|
||||
&& str_contains($contentType, 'text/html')
|
||||
&& 1 !== preg_match(self::IGNORED, $path)
|
||||
&& 1 !== preg_match(self::MACHINES, $userAgent);
|
||||
}
|
||||
|
||||
public function record(string $path, int $status, string $referrer, string $siteHost, \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): void
|
||||
{
|
||||
$path = $this->normalisePath($path, $status);
|
||||
$host = $this->referrerHost($referrer, $siteHost);
|
||||
$key = [
|
||||
'website_id' => 'default',
|
||||
'day' => $now->format('Y-m-d'),
|
||||
'hour' => (int) $now->format('G'),
|
||||
'path' => $path,
|
||||
'status' => $status,
|
||||
'referrer_host' => $host,
|
||||
];
|
||||
// Update first, insert only when there was nothing to update. That is one query in the
|
||||
// common case and needs no platform specific upsert syntax, which matters because the
|
||||
// target database is MySQL and the development one is SQLite.
|
||||
if (1 === $this->increment($key)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->db->insert('stat_hit', [...$key, 'count' => 1]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Another request inserted the same row in between; then incrementing works.
|
||||
$this->increment($key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbers for a range of days, ending today.
|
||||
*
|
||||
* @return array{
|
||||
* days: list<array{day: string, count: int}>,
|
||||
* total: int, pages: list<array{path: string, count: int}>,
|
||||
* referrers: list<array{host: string, count: int}>,
|
||||
* missing: list<array{path: string, count: int}>,
|
||||
* hours: list<array{hour: int, count: int}>,
|
||||
* range: int, from: string, compactedBefore: string, busiest: int
|
||||
* }
|
||||
*/
|
||||
public function overview(int $range = 30, \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array
|
||||
{
|
||||
$range = max(1, min(365, $range));
|
||||
$from = $now->modify(sprintf('-%d days', $range - 1))->format('Y-m-d');
|
||||
$where = 'website_id = ? AND day >= ?';
|
||||
$params = ['default', $from];
|
||||
|
||||
$perDay = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT day, SUM(count) AS total FROM stat_hit WHERE '.$where.' AND status = 200 GROUP BY day',
|
||||
$params,
|
||||
) as $row) {
|
||||
$perDay[$row['day']] = (int) $row['total'];
|
||||
}
|
||||
// Every day in the range, including the ones with nothing: a gap in a chart is a
|
||||
// statement, a missing bar is a puzzle.
|
||||
$days = [];
|
||||
for ($index = $range - 1; $index >= 0; --$index) {
|
||||
$day = $now->modify(sprintf('-%d days', $index))->format('Y-m-d');
|
||||
$days[] = ['day' => $day, 'count' => $perDay[$day] ?? 0];
|
||||
}
|
||||
|
||||
$rows = fn (string $select, string $extra, string $column): array => array_map(
|
||||
static fn (array $row): array => [$column => (string) $row[$column], 'count' => (int) $row['total']],
|
||||
$this->db->fetchAllAssociative(
|
||||
sprintf('SELECT %s, SUM(count) AS total FROM stat_hit WHERE %s %s GROUP BY %s ORDER BY total DESC LIMIT 15', $select, $where, $extra, $select),
|
||||
$params,
|
||||
),
|
||||
);
|
||||
|
||||
$hours = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT hour, SUM(count) AS total FROM stat_hit WHERE '.$where.' AND status = 200 AND hour >= 0 GROUP BY hour',
|
||||
$params,
|
||||
) as $row) {
|
||||
$hours[(int) $row['hour']] = (int) $row['total'];
|
||||
}
|
||||
|
||||
return [
|
||||
'days' => $days,
|
||||
'total' => array_sum(array_column($days, 'count')),
|
||||
'busiest' => max(1, max(array_column($days, 'count'))),
|
||||
'pages' => $rows('path', 'AND status = 200', 'path'),
|
||||
'referrers' => $rows('referrer_host', "AND status = 200 AND referrer_host <> ''", 'referrer_host'),
|
||||
'missing' => $rows('path', 'AND status = 404', 'path'),
|
||||
'hours' => array_map(static fn (int $hour): array => ['hour' => $hour, 'count' => $hours[$hour] ?? 0], range(0, 23)),
|
||||
'range' => $range,
|
||||
'from' => $from,
|
||||
'compactedBefore' => $now->modify(sprintf('-%d days', self::COMPACT_AFTER_DAYS))->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
/** Every counted row of a range, for the CSV export. */
|
||||
public function export(int $range, \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): iterable
|
||||
{
|
||||
return $this->db->iterateAssociative(
|
||||
'SELECT day, hour, path, status, referrer_host, count FROM stat_hit WHERE website_id = ? AND day >= ? ORDER BY day DESC, hour DESC, count DESC',
|
||||
['default', $now->modify(sprintf('-%d days', max(1, min(365, $range)) - 1))->format('Y-m-d')],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the hours of older days into one row each and drops what is beyond the retention.
|
||||
*
|
||||
* @return array{compacted: int, removed: int}
|
||||
*/
|
||||
public function prune(\DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array
|
||||
{
|
||||
$removed = $this->db->executeStatement(
|
||||
'DELETE FROM stat_hit WHERE website_id = ? AND day < ?',
|
||||
['default', $now->modify(sprintf('-%d days', self::KEEP_DAYS))->format('Y-m-d')],
|
||||
);
|
||||
|
||||
$border = $now->modify(sprintf('-%d days', self::COMPACT_AFTER_DAYS))->format('Y-m-d');
|
||||
$compacted = 0;
|
||||
$this->db->transactional(function () use ($border, &$compacted): void {
|
||||
$groups = $this->db->fetchAllAssociative(
|
||||
'SELECT day, path, status, referrer_host, SUM(count) AS total FROM stat_hit
|
||||
WHERE website_id = ? AND day < ? AND hour >= 0
|
||||
GROUP BY day, path, status, referrer_host',
|
||||
['default', $border],
|
||||
);
|
||||
foreach ($groups as $group) {
|
||||
$key = ['website_id' => 'default', 'day' => $group['day'], 'path' => $group['path'], 'status' => (int) $group['status'], 'referrer_host' => $group['referrer_host']];
|
||||
$this->db->executeStatement(
|
||||
'DELETE FROM stat_hit WHERE website_id = ? AND day = ? AND path = ? AND status = ? AND referrer_host = ?',
|
||||
array_values($key),
|
||||
);
|
||||
$this->db->insert('stat_hit', [...$key, 'hour' => -1, 'count' => (int) $group['total']]);
|
||||
++$compacted;
|
||||
}
|
||||
});
|
||||
|
||||
return ['compacted' => $compacted, 'removed' => $removed];
|
||||
}
|
||||
|
||||
private function increment(array $key): int
|
||||
{
|
||||
return $this->db->executeStatement(
|
||||
'UPDATE stat_hit SET count = count + 1 WHERE website_id = ? AND day = ? AND hour = ? AND path = ? AND status = ? AND referrer_host = ?',
|
||||
array_values($key),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A 404 keeps its address only if it looks like an address of this site. Crawlers probe
|
||||
* thousands of invented paths; each one would otherwise become a row of its own.
|
||||
*/
|
||||
private function normalisePath(string $path, int $status): string
|
||||
{
|
||||
$path = '/'.trim(parse_url($path, PHP_URL_PATH) ?: '/', '/');
|
||||
if (strlen($path) > 200 || (404 === $status && 1 !== preg_match('#^/([a-z0-9]+(?:-[a-z0-9]+)*)(/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$#D', $path))) {
|
||||
return self::OTHER;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** The bare host of an external referrer; empty for a direct visit or a link from this site. */
|
||||
private function referrerHost(string $referrer, string $siteHost): string
|
||||
{
|
||||
$host = strtolower((string) parse_url($referrer, PHP_URL_HOST));
|
||||
if ('' === $host || $host === strtolower($siteHost) || strlen($host) > 100) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 1 === preg_match('/^[a-z0-9.-]+$/D', $host) ? $host : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Statistics;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class StatisticsController extends AbstractController
|
||||
{
|
||||
private const RANGES = [7 => '7 Tage', 30 => '30 Tage', 90 => '90 Tage', 365 => '1 Jahr'];
|
||||
|
||||
public function __construct(private Statistics $statistics)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/statistik', name: 'admin_statistics', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$range = isset(self::RANGES[$request->query->getInt('tage')]) ? $request->query->getInt('tage') : 30;
|
||||
|
||||
return $this->render('admin/statistics.html.twig', [
|
||||
'stats' => $this->statistics->overview($range),
|
||||
'ranges' => self::RANGES,
|
||||
'range' => $range,
|
||||
'compactAfter' => Statistics::COMPACT_AFTER_DAYS,
|
||||
'keepDays' => Statistics::KEEP_DAYS,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/statistik.csv', name: 'admin_statistics_csv', methods: ['GET'])]
|
||||
public function csv(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$range = isset(self::RANGES[$request->query->getInt('tage')]) ? $request->query->getInt('tage') : 30;
|
||||
|
||||
// Streamed, because a year of counters is a lot of rows and none of them need to be held
|
||||
// in memory at once.
|
||||
$response = new StreamedResponse(function () use ($range): void {
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['tag', 'stunde', 'pfad', 'status', 'verweis-domain', 'anzahl'], ';', '"', '');
|
||||
foreach ($this->statistics->export($range) as $row) {
|
||||
fputcsv($out, [
|
||||
$row['day'],
|
||||
-1 === (int) $row['hour'] ? 'ganzer Tag' : $row['hour'],
|
||||
$row['path'],
|
||||
$row['status'],
|
||||
$row['referrer_host'],
|
||||
$row['count'],
|
||||
], ';', '"', '');
|
||||
}
|
||||
fclose($out);
|
||||
});
|
||||
$response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="kantsites-statistik-%d-tage.csv"', $range));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Content\Statistics;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Counts a delivered page after the response has gone out.
|
||||
*
|
||||
* On kernel.terminate, so the counting never delays an answer and a failure while counting can
|
||||
* never break a page: a statistic is not worth a broken website.
|
||||
*/
|
||||
final class StatisticsSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private Statistics $statistics)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::TERMINATE => 'onTerminate'];
|
||||
}
|
||||
|
||||
public function onTerminate(TerminateEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
$response = $event->getResponse();
|
||||
if (!$this->statistics->countable(
|
||||
$request->getMethod(),
|
||||
$request->getPathInfo(),
|
||||
$response->getStatusCode(),
|
||||
(string) $response->headers->get('Content-Type'),
|
||||
(string) $request->headers->get('User-Agent'),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->statistics->record(
|
||||
$request->getPathInfo(),
|
||||
$response->getStatusCode(),
|
||||
(string) $request->headers->get('Referer'),
|
||||
$request->getHost(),
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// A database that is momentarily unavailable loses a count. That is the right price;
|
||||
// the alternative would be an error page for a visitor who did nothing wrong.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
{id: 'partials', route: 'admin_partials', icon: '◫', label: 'Bereiche'},
|
||||
{id: 'menus', route: 'admin_menus', icon: '☰', label: 'Menüs'},
|
||||
{id: 'media', route: 'admin_media', icon: '▩', label: 'Mediathek'},
|
||||
{id: 'forms', route: 'admin_forms', icon: '✉', label: 'Formulare'}
|
||||
{id: 'forms', route: 'admin_forms', icon: '✉', label: 'Formulare'},
|
||||
{id: 'statistics', route: 'admin_statistics', icon: '▁', label: 'Statistik'}
|
||||
] %}
|
||||
{% for entry in entries %}
|
||||
<li><a class="nav-item" href="{{ path(entry.route) }}"{{ nav|default('') == entry.id ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">{{ entry.icon }}</span>{{ entry.label }}</a></li>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'statistics' %}
|
||||
{% block title %}Statistik{% endblock %}
|
||||
{% block heading %}Statistik{% endblock %}
|
||||
{% block lead %}Gezählt wird auf dem Server, ohne Cookie und ohne IP-Adresse – deshalb braucht diese Messung keine Einwilligung. Zeiten in UTC.{% endblock %}
|
||||
{% block actions %}
|
||||
<a class="secondary" href="{{ path('admin_statistics_csv', {tage: range}) }}">Als CSV</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<nav class="range-switch" aria-label="Zeitraum">
|
||||
{% for days, label in ranges %}
|
||||
<a class="{{ days == range ? 'primary' : 'secondary' }}" href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Seitenaufrufe <span class="count">{{ stats.total }}</span></h2>
|
||||
<span class="muted">ab {{ stats.from|date('d.m.Y') }}{% if stats.total > 0 %} · im Schnitt {{ (stats.total / stats.range)|number_format(stats.total / stats.range < 10 ? 1 : 0, ',', '.') }} am Tag{% endif %}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.total == 0 %}
|
||||
<p class="muted">Noch keine Aufrufe in diesem Zeitraum. Gezählt wird erst, wenn eine veröffentlichte Seite tatsächlich abgerufen wird – Aufrufe der Verwaltung und erkennbare Suchmaschinen zählen nicht mit.</p>
|
||||
{% else %}
|
||||
{# Drawn by the server as an SVG: no chart library, no script, and it prints. The bars are
|
||||
coloured by class, not by a style attribute, because inline styles are forbidden here.
|
||||
The numbers themselves are in the table below, which is what a screen reader reads. #}
|
||||
<svg class="chart" viewBox="0 0 {{ stats.days|length * 12 }} 116" role="img"
|
||||
aria-label="{{ stats.total }} Aufrufe zwischen {{ stats.from|date('d.m.Y') }} und heute, höchster Tageswert {{ stats.busiest }}" preserveAspectRatio="none">
|
||||
{% for entry in stats.days %}
|
||||
{% set height = entry.count > 0 ? (entry.count / stats.busiest * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ stats.days|length * 12 }}" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>höchster Tag: {{ stats.busiest }}</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||||
|
||||
<details class="chart-table">
|
||||
<summary>Zahlen je Tag</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<caption class="visually-hidden">Aufrufe je Tag im gewählten Zeitraum</caption>
|
||||
<thead><tr><th scope="col">Tag</th><th scope="col">Aufrufe</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in stats.days|reverse %}
|
||||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ entry.count }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.total > 0 %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde, nur die letzten {{ compactAfter }} Tage sind stundengenau</span></div>
|
||||
<div class="card-body">
|
||||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||||
{% set busiestHour = max(stats.hours|map(entry => entry.count)|filter(count => count > 0)|default([1])) %}
|
||||
{% for entry in stats.hours %}
|
||||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>0 Uhr</span><span>12 Uhr</span><span>23 Uhr</span></p>
|
||||
<p class="field-help">Ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="stats-columns">
|
||||
{% for group in [
|
||||
{title: 'Seiten', hint: 'Meistaufgerufen', rows: stats.pages, key: 'path', link: true},
|
||||
{title: 'Verweise von außen', hint: 'Domain, die verlinkt hat', rows: stats.referrers, key: 'referrer_host', link: false},
|
||||
{title: 'Nicht gefundene Adressen', hint: 'Kandidaten für eine Weiterleitung', rows: stats.missing, key: 'path', link: false}
|
||||
] %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>{{ group.title }}</h2><span class="muted">{{ group.hint }}</span></div>
|
||||
<div class="card-body">
|
||||
{% if group.rows is empty %}
|
||||
<p class="muted">Keine Daten im Zeitraum.</p>
|
||||
{% else %}
|
||||
<ol class="stats-list">
|
||||
{% for row in group.rows %}
|
||||
<li>
|
||||
{% if group.link %}<a href="{{ row[group.key] }}">{{ row[group.key] }}</a>{% else %}<span>{{ row[group.key] }}</span>{% endif %}
|
||||
<strong>{{ row.count }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Was diese Zahlen nicht sagen</h2><span class="muted">Ehrlich statt vollständig</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Gezählt werden <strong>Aufrufe</strong>, nicht Besucher: ohne Wiedererkennung lässt sich nicht sagen, ob zehn Aufrufe von zehn Personen oder von einer kommen. Es gibt deshalb auch keine Verweildauer, keine Absprungrate, keinen Klickweg durch die Website und kein Land. All das braucht ein Merkmal, das eine Person wiedererkennbar macht – und damit eine Einwilligung.</p>
|
||||
<p class="field-help">Erkennbare Suchmaschinen und Überwachungsdienste werden anhand ihrer Kennung ausgeschlossen. Das ist eine Heuristik: wer sich als Browser ausgibt, wird mitgezählt. Die Zahl ist also „Aufrufe, die sich nicht als Maschine gemeldet haben“, nicht „Menschen“. Erfundene Adressen von Crawlern laufen gesammelt unter <code>(sonstige)</code>, damit die Tabelle nicht mit tausenden Zeilen wächst.</p>
|
||||
<p class="field-help">Aufbewahrt wird {{ keepDays }} Tage, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. Danach wird gelöscht.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
+26
-1
@@ -1792,6 +1792,25 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$this->request('/');
|
||||
$this->request('/');
|
||||
// A page that does not exist counts as a missing address, not as a page view.
|
||||
$this->request('/gibt-es-nicht');
|
||||
// Everything that is not a page of the site stays out of the numbers.
|
||||
$this->request('/admin');
|
||||
$this->request('/feed.xml');
|
||||
$this->request('/robots.txt');
|
||||
|
||||
$rows = $this->db->fetchAllAssociative('SELECT path, status, count FROM stat_hit ORDER BY status');
|
||||
self::assertSame([
|
||||
['path' => '/', 'status' => 200, 'count' => 2],
|
||||
['path' => '/gibt-es-nicht', 'status' => 404, 'count' => 1],
|
||||
], array_map(static fn (array $row): array => ['path' => $row['path'], 'status' => (int) $row['status'], 'count' => (int) $row['count']], $rows));
|
||||
}
|
||||
|
||||
public function testWithoutEmbedsThereIsNoConsentQuestion(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
@@ -1991,7 +2010,13 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
$kernel = new Kernel('test', false);
|
||||
try {
|
||||
return $kernel->handle(Request::create($url, $method, $body, $cookies));
|
||||
$request = Request::create($url, $method, $body, $cookies);
|
||||
$response = $kernel->handle($request);
|
||||
// Counting happens on kernel.terminate, so a test that does not terminate would
|
||||
// silently test a different application than the one that runs.
|
||||
$kernel->terminate($request, $response);
|
||||
|
||||
return $response;
|
||||
} finally {
|
||||
$kernel->shutdown();
|
||||
foreach ($previous as $key => [$server, $env]) {
|
||||
|
||||
@@ -98,7 +98,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
public function testAnonymousCannotReadDraftOrWrite(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus', '/admin/forms'] as $url) {
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus', '/admin/forms', '/admin/statistik', '/admin/statistik.csv'] as $url) {
|
||||
$client->request('GET', $url);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent());
|
||||
@@ -598,6 +598,35 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE path IN (?, ?)', ['datenschutz', 'impressum']));
|
||||
}
|
||||
|
||||
public function testTheStatisticsScreenNeedsASignInAndShowsWhatWasCounted(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
// Two page views and one missing address, counted through real requests.
|
||||
$client->request('GET', '/');
|
||||
$client->request('GET', '/');
|
||||
$client->request('GET', '/gibt-es-nicht');
|
||||
|
||||
$crawler = $client->request('GET', '/admin/statistik');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('2', $crawler->filter('.card-head .count')->first()->text());
|
||||
// Both charts - days and hours - are drawn by the server, so they are in the markup and
|
||||
// need no script.
|
||||
self::assertCount(2, $crawler->filter('svg.chart[role="img"]'));
|
||||
self::assertCount(0, $crawler->filter('svg [style]'), 'Inline styles are forbidden by the policy.');
|
||||
self::assertStringContainsString('/gibt-es-nicht', $crawler->filter('.stats-list')->last()->text());
|
||||
// And it says what it cannot answer.
|
||||
self::assertStringContainsString('nicht Besucher', $crawler->filter('.card-body')->last()->text());
|
||||
|
||||
$client->request('GET', '/admin/statistik.csv?tage=7');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('text/csv', (string) $client->getResponse()->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('attachment; filename="kantsites-statistik-7-tage.csv"', (string) $client->getResponse()->headers->get('Content-Disposition'));
|
||||
// The browser has already streamed the body once; the captured copy is the internal one.
|
||||
$csv = (string) $client->getInternalResponse()->getContent();
|
||||
self::assertStringContainsString('tag;stunde;pfad;status;verweis-domain;anzahl', $csv);
|
||||
self::assertStringContainsString(';/;200;;2', $csv);
|
||||
}
|
||||
|
||||
public function testPasswordUpgradeRefreshesTheSessionUser(): void
|
||||
{
|
||||
$provider = new UserProvider($this->db);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\Statistics;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The measurement, and above all the property that makes it possible without consent: there is no
|
||||
* column from which a person could follow, and nothing is written per visit.
|
||||
*/
|
||||
final class StatisticsTest extends TestCase
|
||||
{
|
||||
private Connection $db;
|
||||
private Statistics $stats;
|
||||
private string $path;
|
||||
private \DateTimeImmutable $now;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-stats-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||||
TestSchema::build($this->db);
|
||||
$this->stats = new Statistics($this->db);
|
||||
$this->now = new \DateTimeImmutable('2026-09-12 14:30:00', new \DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
}
|
||||
|
||||
public function testTheTableHoldsNothingThatPointsAtAPerson(): void
|
||||
{
|
||||
$columns = array_map(
|
||||
static fn (object $column): string => $column->getName(),
|
||||
$this->db->createSchemaManager()->listTableColumns('stat_hit'),
|
||||
);
|
||||
// The whole design rests on this: counters, not records.
|
||||
self::assertSame(['id', 'website_id', 'day', 'hour', 'path', 'status', 'referrer_host', 'count'], array_values($columns));
|
||||
foreach (['ip', 'address', 'user', 'agent', 'session', 'cookie', 'visitor', 'hash', 'fingerprint'] as $forbidden) {
|
||||
foreach ($columns as $column) {
|
||||
self::assertStringNotContainsString($forbidden, $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testManyRequestsBecomeOneCounter(): void
|
||||
{
|
||||
for ($index = 0; $index < 5; ++$index) {
|
||||
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now);
|
||||
}
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM stat_hit');
|
||||
self::assertSame(['2026-09-12', 14, '/leistungen', 200, '', 5], [
|
||||
$row['day'], (int) $row['hour'], $row['path'], (int) $row['status'], $row['referrer_host'], (int) $row['count'],
|
||||
]);
|
||||
|
||||
// A different hour, page, status or referrer is a different counter - and nothing else is.
|
||||
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now->modify('+1 hour'));
|
||||
$this->stats->record('/kontakt', 200, '', 'kant-it.de', $this->now);
|
||||
$this->stats->record('/leistungen', 404, '', 'kant-it.de', $this->now);
|
||||
$this->stats->record('/leistungen', 200, 'https://www.google.de/search?q=kant', 'kant-it.de', $this->now);
|
||||
self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
|
||||
self::assertSame('www.google.de', $this->db->fetchOne('SELECT referrer_host FROM stat_hit WHERE referrer_host <> ?', ['']));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function referrers(): iterable
|
||||
{
|
||||
yield 'external' => ['https://www.google.de/search?q=kant+it', 'www.google.de'];
|
||||
yield 'own site is not a referrer' => ['https://kant-it.de/leistungen', ''];
|
||||
yield 'own site in other case' => ['https://KANT-IT.de/leistungen', ''];
|
||||
yield 'direct visit' => ['', ''];
|
||||
// Only the host is kept, never the address someone came from: that one can carry a search
|
||||
// term, a name or a token in its query string.
|
||||
yield 'no path and no query survive' => ['https://forum.example/thread/123?user=anna', 'forum.example'];
|
||||
yield 'nonsense' => ['nicht-einmal-eine-adresse', ''];
|
||||
yield 'host with a colon' => ['https://host:mit@doppelpunkt.example/x', 'doppelpunkt.example'];
|
||||
}
|
||||
|
||||
#[DataProvider('referrers')]
|
||||
public function testOnlyTheBareHostOfAnExternalReferrerIsKept(string $referrer, string $expected): void
|
||||
{
|
||||
$this->stats->record('/', 200, $referrer, 'kant-it.de', $this->now);
|
||||
self::assertSame($expected, $this->db->fetchOne('SELECT referrer_host FROM stat_hit'));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, int, string}> */
|
||||
public static function paths(): iterable
|
||||
{
|
||||
yield 'a page' => ['/leistungen/webdesign', 200, '/leistungen/webdesign'];
|
||||
yield 'the root' => ['/', 200, '/'];
|
||||
yield 'query string is dropped' => ['/leistungen?utm_source=newsletter', 200, '/leistungen'];
|
||||
yield 'a plausible 404 keeps its address' => ['/alte-seite', 404, '/alte-seite'];
|
||||
// Crawlers probe thousands of invented addresses; each one would be a row of its own.
|
||||
yield 'a probed 404 is bucketed' => ['/wp-admin/setup-config.php', 404, Statistics::OTHER];
|
||||
yield 'a very long path is bucketed' => ['/'.str_repeat('a', 260), 200, Statistics::OTHER];
|
||||
}
|
||||
|
||||
#[DataProvider('paths')]
|
||||
public function testWhichAddressIsStored(string $path, int $status, string $expected): void
|
||||
{
|
||||
$this->stats->record($path, $status, '', 'kant-it.de', $this->now);
|
||||
self::assertSame($expected, $this->db->fetchOne('SELECT path FROM stat_hit'));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array{string, string, int, string, string}, bool}> */
|
||||
public static function requests(): iterable
|
||||
{
|
||||
yield 'a page' => [['GET', '/leistungen', 200, 'text/html; charset=UTF-8', 'Mozilla/5.0 (Windows NT 10.0)'], true];
|
||||
yield 'a missing page' => [['GET', '/weg', 404, 'text/html', 'Mozilla/5.0'], true];
|
||||
yield 'the administration' => [['GET', '/admin/statistik', 200, 'text/html', 'Mozilla/5.0'], false];
|
||||
yield 'an image' => [['GET', '/media/abc/480', 200, 'image/webp', 'Mozilla/5.0'], false];
|
||||
yield 'a stylesheet' => [['GET', '/assets/site.css', 200, 'text/css', 'Mozilla/5.0'], false];
|
||||
yield 'the feed' => [['GET', '/feed.xml', 200, 'application/xml', 'Mozilla/5.0'], false];
|
||||
yield 'the consent page' => [['GET', '/einwilligung', 200, 'text/html', 'Mozilla/5.0'], false];
|
||||
yield 'a redirect' => [['GET', '/alt', 301, 'text/html', 'Mozilla/5.0'], false];
|
||||
yield 'a form submission' => [['POST', '/kontakt', 200, 'text/html', 'Mozilla/5.0'], false];
|
||||
yield 'Googlebot' => [['GET', '/', 200, 'text/html', 'Mozilla/5.0 (compatible; Googlebot/2.1)'], false];
|
||||
yield 'a monitor' => [['GET', '/', 200, 'text/html', 'Uptime-Monitor/1.0'], false];
|
||||
yield 'headless Chrome' => [['GET', '/', 200, 'text/html', 'HeadlessChrome/120'], false];
|
||||
yield 'curl' => [['GET', '/', 200, 'text/html', 'curl/8.4.0'], false];
|
||||
yield 'no user agent at all' => [['GET', '/', 200, 'text/html', ''], true];
|
||||
}
|
||||
|
||||
#[DataProvider('requests')]
|
||||
public function testWhatIsCountedAtAll(array $request, bool $expected): void
|
||||
{
|
||||
[$method, $path, $status, $type, $agent] = $request;
|
||||
self::assertSame($expected, $this->stats->countable($method, $path, $status, $type, $agent));
|
||||
}
|
||||
|
||||
public function testTheOverviewFillsEveryDayAndSeparatesFoundFromMissing(): void
|
||||
{
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify('-3 days'));
|
||||
foreach (range(1, 4) as $ignored) {
|
||||
$this->stats->record('/leistungen', 200, 'https://suche.example/x', 'kant-it.de', $this->now);
|
||||
}
|
||||
$this->stats->record('/gibt-es-nicht', 404, '', 'kant-it.de', $this->now);
|
||||
|
||||
$overview = $this->stats->overview(7, $this->now);
|
||||
self::assertCount(7, $overview['days'], 'Every day of the range, including the empty ones.');
|
||||
self::assertSame(5, $overview['total'], 'A 404 is not a page view.');
|
||||
// Index 6 is today, index 3 is three days ago.
|
||||
self::assertSame([0, 0, 0, 1, 0, 0, 4], array_column($overview['days'], 'count'));
|
||||
self::assertSame(4, $overview['busiest']);
|
||||
self::assertSame([['path' => '/leistungen', 'count' => 4], ['path' => '/', 'count' => 1]], $overview['pages']);
|
||||
self::assertSame([['referrer_host' => 'suche.example', 'count' => 4]], $overview['referrers']);
|
||||
self::assertSame([['path' => '/gibt-es-nicht', 'count' => 1]], $overview['missing']);
|
||||
// The hours are summed over the whole range, so both days contribute to 14:00.
|
||||
self::assertSame(5, $overview['hours'][14]['count']);
|
||||
self::assertCount(24, $overview['hours']);
|
||||
}
|
||||
|
||||
public function testOlderDaysAreCompactedAndTheOldestAreRemoved(): void
|
||||
{
|
||||
// Two hours of the same day, long enough ago to be compacted.
|
||||
$old = $this->now->modify('-70 days');
|
||||
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $old);
|
||||
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $old->modify('+2 hours'));
|
||||
// Yesterday stays untouched, and something beyond the retention goes.
|
||||
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now->modify('-1 day'));
|
||||
$this->stats->record('/leistungen', 200, '', 'kant-it.de', $this->now->modify('-500 days'));
|
||||
self::assertSame(4, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
|
||||
|
||||
$result = $this->stats->prune($this->now);
|
||||
self::assertSame(['compacted' => 1, 'removed' => 1], $result);
|
||||
// The two hours became one row with the sum, and the total is unchanged.
|
||||
$row = $this->db->fetchAssociative('SELECT hour, count FROM stat_hit WHERE day = ?', [$old->format('Y-m-d')]);
|
||||
self::assertSame([-1, 2], [(int) $row['hour'], (int) $row['count']]);
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM stat_hit'));
|
||||
// Pruning twice changes nothing further.
|
||||
self::assertSame(['compacted' => 0, 'removed' => 0], $this->stats->prune($this->now));
|
||||
|
||||
// A compacted day still counts in the overview, it just has no hour any more.
|
||||
$overview = $this->stats->overview(90, $this->now);
|
||||
self::assertSame(3, $overview['total']);
|
||||
self::assertSame(0, array_sum(array_column($overview['hours'], 'count')) - 1, 'Only yesterday is still hour precise.');
|
||||
}
|
||||
|
||||
public function testTheExportReturnsEveryCounterOfTheRange(): void
|
||||
{
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now);
|
||||
$this->stats->record('/weg', 404, '', 'kant-it.de', $this->now);
|
||||
$this->stats->record('/alt', 200, '', 'kant-it.de', $this->now->modify('-40 days'));
|
||||
|
||||
$rows = [...$this->stats->export(7, $this->now)];
|
||||
self::assertCount(2, $rows, 'Only what lies in the range.');
|
||||
self::assertSame(3, count([...$this->stats->export(90, $this->now)]));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use DoctrineMigrations\Version20260912001000;
|
||||
use DoctrineMigrations\Version20260912001100;
|
||||
use DoctrineMigrations\Version20260912001200;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,7 @@ final class TestSchema
|
||||
Version20260912000900::class,
|
||||
Version20260912001000::class,
|
||||
Version20260912001100::class,
|
||||
Version20260912001200::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user