From 7d5a4f3f290fd1615de91015b5ebd1baf359d8d1 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:40 +0200 Subject: [PATCH] docs: README mit Deployment-, Bedienungs- und Sicherheitsteil Zwei Deployment-Wege (Shared Hosting per FTP in zwei Varianten sowie Docker), vollstaendige Konfigurationsreferenz, Bedienungsanleitung und eine Anleitung, was fuer eine weitere Domain beim Provider zu tun ist - inklusive Fehlersuche. --- README.md | 668 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5b88ae --- /dev/null +++ b/README.md @@ -0,0 +1,668 @@ +# Kurzlinks – URL-Shortener mit QR-Code-Generator + +Ein schlanker, selbst gehosteter URL-Shortener mit eingebautem QR-Code-Generator. +Gedacht als leichtgewichtige Alternative zu Shlink: kein Framework, kein Build-Schritt, +keine Node-Toolchain – nur PHP, eine Datenbank und ein Webserver. + +**Alles liegt hinter einem Login.** Öffentlich erreichbar sind ausschließlich die +Weiterleitungen `/{slug}` – Übersicht, Verwaltung, Statistiken und QR-Codes verlangen +Benutzername und Passwort. + +--- + +## Inhalt + +- [Funktionsumfang](#funktionsumfang) +- [Systemvoraussetzungen](#systemvoraussetzungen) +- [Deployment A: Shared Webhosting per FTP](#deployment-a-shared-webhosting-per-ftp) +- [Deployment B: Docker](#deployment-b-docker) +- [Konfigurationsreferenz](#konfigurationsreferenz) +- [Bedienung](#bedienung) +- [Projektstruktur](#projektstruktur) +- [Sicherheit und Datenschutz](#sicherheit-und-datenschutz) +- [Tests](#tests) +- [Fehlerbehebung](#fehlerbehebung) + +--- + +## Funktionsumfang + +**Kurzlinks** +- **Mehrere Domains** auf einer Installation: `short.example.de/messe` und `qr.example.de/messe` + können auf verschiedene Ziele zeigen. Slugs sind je Domain eindeutig. Ein eingebauter + **Domain-Check** prüft auf Knopfdruck, ob DNS, Webserver-Zuordnung und Zertifikat stimmen. +- Lange URL eintragen → Kurzlink erzeugen +- Zufälliger Slug (kollisionssicher, 7 Zeichen, wächst bei Kollisionen automatisch) + oder eigener Wunsch-Slug wie `deine-domain.de/messe2026` +- URL-Validierung: ausschließlich `http` und `https` – innerhalb davon aber großzügig + (Umlaut-Domains, Umlaute in Pfad/Query, IPv6, interne Hostnamen ohne Punkt) +- Weiterleitung per HTTP 301/302/307/308 (konfigurierbar) +- Freundliche 404-Seite für unbekannte oder deaktivierte Slugs +- Liste mit Suche und Sortierung; bearbeiten, aktivieren/deaktivieren, löschen + +**Statistiken** +- Jeder Aufruf wird protokolliert: Zeitpunkt, Referrer, Gerätetyp (Desktop/Mobil/Tablet/Bot), + Browser und Plattform +- **Keine IP-Adressen** – standardmäßig wird gar nichts gespeichert, optional nur ein + tageweise rotierender, gesalzener Hash +- Detailseite je Link: Gesamtklicks, Liniendiagramm über 7/30/90/365 Tage, + Top-Referrer, Aufteilung nach Gerät, Browser und Plattform, letzte Aufrufe +- Die Protokollierung läuft **nach** dem Ausliefern der Weiterleitung + (`fastcgi_finish_request`) und verzögert den Redirect daher nicht spürbar + +**QR-Codes** (erzeugt mit [`endroid/qr-code`](https://github.com/endroid/qr-code)) +- Vorder- und Hintergrundfarbe per Farbwähler oder Hex-Eingabe, mit Farbvoreinstellungen +- Automatische Kontrastwarnung (WCAG-Verhältnis < 4:1 sowie invertierte Codes) +- Logo in der Mitte (PNG, JPG, SVG) – Fehlerkorrektur wird dabei automatisch auf **H** gesetzt +- Größe 256/512/1024/2048 px, Randbreite (Quiet Zone) 0–10 Module +- Download als **PNG** (Web) und **SVG** (vektorbasiert für den Druck) +- Live-Vorschau im Browser beim Anpassen + +**Sicherheit** +- Setup-Assistent beim ersten Start oder feste Zugangsdaten in der `.env` +- Session-Login mit bcrypt-Hash, CSRF-Token für alle Formulare, Rate-Limiting am Login +- Vorbereitete Statements überall, Output-Escaping, Security-Header inkl. CSP + +--- + +## Systemvoraussetzungen + +| | | +|---|---| +| PHP | 8.2 oder neuer | +| Erweiterungen | `pdo` + `pdo_sqlite` (Standard) bzw. `pdo_mysql`, `gd`, `json`, `mbstring`, `fileinfo` | +| Optional | `imagick` – nur nötig, wenn SVG-Logos auch im **PNG**-Download erscheinen sollen | +| Optional | `intl` – für Umlaut-Domains nach UTS-46; fehlt sie, greift ein eingebauter Punycode-Fallback | +| Datenbank | SQLite (eingebaut) oder MySQL/MariaDB 5.7+ | +| Webserver | Apache mit `mod_rewrite` (`.htaccess` liegt bei) oder Nginx | + +--- + +## Deployment A: Shared Webhosting per FTP + +Es wird **keine Shell** benötigt. Der `vendor/`-Ordner ist im Release-Paket enthalten. + +### Paket vorbereiten + +Wenn du das Projekt aus dem Repository nutzt und Composer lokal (oder per Docker) hast: + +```bash +composer install --no-dev --optimize-autoloader +``` + +Danach enthält der Ordner alles, was hochgeladen werden muss. + +### Variante A1 – Webroot kann auf `public/` gezeigt werden (empfohlen) + +Viele Hoster erlauben es, das Dokumentenstammverzeichnis einer (Sub-)Domain frei zu wählen. + +1. Kompletten Projektordner per FTP/SFTP hochladen, z. B. nach `/kurzlinks`. +2. Im Hosting-Panel den Webroot der Domain auf `/kurzlinks/public` setzen. +3. Schreibrechte für den Ordner `data/` setzen (meist `755`, bei manchen Hostern `775`). +4. `.env.example` nach `.env` kopieren und mindestens `APP_BASE_URL` eintragen: + + ```dotenv + APP_BASE_URL=https://kurz.deine-domain.de + ``` + +5. Domain im Browser aufrufen → der **Setup-Assistent** legt das Admin-Konto an. + +Bei dieser Variante liegen `src/`, `templates/`, `data/` und `vendor/` außerhalb +des Webroots und sind damit grundsätzlich nicht abrufbar. + +### Variante A2 – nur ein fester Webroot (z. B. `httpdocs`) + +Wenn der Webroot nicht verschoben werden kann: + +1. **Den gesamten Projektinhalt** (inklusive `public/`, `src/`, `vendor/`, `data/`, + der beiliegenden `.htaccess` im Stammverzeichnis und der `.env`) direkt in den + Webroot hochladen. +2. Schreibrechte für `data/` setzen. +3. `.env` anlegen wie oben. +4. Domain aufrufen → Setup-Assistent. + +Die mitgelieferte `.htaccess` im Stammverzeichnis leitet alle Anfragen intern nach +`public/` weiter und sperrt gleichzeitig `src/`, `templates/`, `migrations/`, +`vendor/`, `data/` sowie `.env`, `composer.json` und Markdown-Dateien. +Zusätzlich liegt in `data/` eine eigene `.htaccess` als zweite Absicherung. + +> **Bitte prüfen:** Rufe nach dem Upload einmal `https://deine-domain.de/.env` und +> `https://deine-domain.de/data/database.sqlite` auf. Beides muss `403` oder `404` +> liefern. Falls nicht, unterstützt der Hoster `AllowOverride` nicht ausreichend – +> dann bitte Variante A1 verwenden. + +### Wichtige Hinweise zu Shared Hosting + +- **`mod_rewrite` muss aktiv sein.** Ohne Rewrites funktionieren die Kurzlinks nicht. +- Läuft die Anwendung in einem **Unterverzeichnis** (`https://domain.de/kurz/`), + wird das automatisch erkannt; `APP_BASE_URL` muss dann `https://domain.de/kurz` + lauten. +- Die SQLite-Datei liegt in `data/database.sqlite` und wird beim ersten Aufruf + automatisch samt Schema angelegt. +- **Backup:** Es genügt, den Ordner `data/` zu sichern (Datenbank + Logos). + +--- + +## Deployment B: Docker + +### Schnellstart (SQLite, nichts weiter nötig) + +```bash +cp .env.example .env # optional – Defaults funktionieren auch ohne +docker compose up -d --build +``` + +Danach: → der Setup-Assistent fragt Benutzername und Passwort ab. + +Der Container basiert auf `php:8.3-apache` und verwendet dieselbe `.htaccess` wie das +Shared Hosting – Docker und FTP-Deployment verhalten sich dadurch identisch. +Datenbank und Logos liegen im benannten Volume `app-data` und überleben ein `--build`. + +Anderer Port: + +```bash +APP_PORT=9000 APP_BASE_URL=http://localhost:9000 docker compose up -d --build +``` + +### Mit MariaDB statt SQLite + +```bash +DB_DRIVER=mysql docker compose --profile mysql up -d --build +``` + +Die App wartet beim Start, bis die Datenbank erreichbar ist, und legt das Schema +selbst an. + +### Konfiguration im Container + +Die Werte lassen sich auf zwei Wegen setzen: + +- **Umgebungsvariablen** in `docker-compose.yml` bzw. einer `.env` neben der + Compose-Datei (Compose setzt sie als Container-Umgebung), **oder** +- eine in den Container gemountete `.env`-Datei. + +> Achtung: Eine im Projekt vorhandene `.env` hat **Vorrang** vor +> Container-Umgebungsvariablen. Für Docker also entweder das eine oder das andere +> verwenden – nicht beides gemischt. + +### Nützliche Befehle + +```bash +docker compose logs -f app # Logs ansehen +docker compose exec app sh # Shell im Container +docker compose down # stoppen (Daten bleiben im Volume) +docker compose down -v # stoppen und Daten löschen +``` + +--- + +## Konfigurationsreferenz + +Alle Werte stehen in der `.env` (Vorlage: `.env.example`) oder als Umgebungsvariablen. + +### Anwendung + +| Schlüssel | Standard | Bedeutung | +|---|---|---| +| `APP_BASE_URL` | *(aus dem Request)* | Basis-URL **ohne** Schrägstrich am Ende. Daraus wird beim ersten Start die Standarddomain angelegt. Weitere Domains werden danach im Admin-Bereich verwaltet – `APP_BASE_URL` dient dann nur noch als Rückfallwert. | +| `APP_ENV` | `production` | `development` zeigt Fehlerdetails im Browser an. | +| `APP_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Anzeige. Gespeichert wird immer UTC. | +| `REDIRECT_STATUS` | `302` | `301`/`308` = permanent (wird vom Browser gecacht → es werden weniger Klicks gezählt), `302`/`307` = temporär (empfohlen). Ungültige Werte fallen auf `302` zurück. | + +### Datenbank + +**Standardmäßig SQLite** – eine einzelne Datei unter `data/database.sqlite`, angesprochen +über PDO. Es ist kein Datenbankserver nötig, nichts einzurichten und nichts zu warten; die +Datei wird beim ersten Aufruf samt Schema automatisch angelegt. Für einen URL-Shortener ist +das die passende Wahl: Weiterleitungen sind reine Lesezugriffe über einen Index, und +Schreibvorgänge (ein `UPDATE` plus ein `INSERT` je Klick) sind selten genug, dass SQLite +auch bei einigen hunderttausend Klicks im Monat nicht ins Schwitzen kommt. Die Anwendung +schaltet dafür `WAL` ein, sodass sich Lesen und Schreiben nicht gegenseitig blockieren. + +**Optional MySQL/MariaDB** – sinnvoll, wenn ohnehin ein Datenbankserver läuft, wenn +mehrere Webserver auf denselben Datenbestand zugreifen sollen oder wenn die vorhandene +Backup-Routine auf Datenbanken ausgelegt ist. Umschalten über `DB_DRIVER=mysql`; das +Schema wird dort ebenso automatisch angelegt. + +Beide Wege nutzen dieselben vorbereiteten Statements – ein Wechsel ändert nichts an der +Bedienung. Ein *Umzug* der Daten von SQLite nach MySQL ist allerdings nicht eingebaut; +dafür bräuchte es einen Export/Import von Hand. + +Tabellen: `domains`, `links`, `visits`, `qr_designs`, `settings`, `login_attempts` +sowie `migrations` (welche Migration bereits gelaufen ist). + +**Backup:** Bei SQLite genügt es, den Ordner `data/` zu sichern – er enthält Datenbank und +hochgeladene Logos. Am saubersten im laufenden Betrieb mit +`sqlite3 data/database.sqlite ".backup data/backup.sqlite"`; ein einfaches Kopieren der +Datei funktioniert in der Praxis ebenfalls, sofern gerade nicht geschrieben wird. + +| Schlüssel | Standard | Bedeutung | +|---|---|---| +| `DB_DRIVER` | `sqlite` | `sqlite` oder `mysql` | +| `DB_SQLITE_PATH` | `data/database.sqlite` | Pfad zur SQLite-Datei, relativ zum Projektstamm. | +| `DB_HOST` / `DB_PORT` | `127.0.0.1` / `3306` | nur bei `mysql` | +| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `urlshort` / `urlshort` / – | nur bei `mysql` | +| `DB_CHARSET` | `utf8mb4` | nur bei `mysql` | + +Das Schema wird beim ersten Request automatisch angelegt (`migrations/`), ein +manueller Migrationsschritt entfällt. + +### Admin-Zugang + +| Schlüssel | Standard | Bedeutung | +|---|---|---| +| `ADMIN_USER` | `admin` | Benutzername | +| `ADMIN_PASSWORD_HASH` | *(leer)* | bcrypt-Hash. **Leer** → beim ersten Aufruf startet der Setup-Assistent und speichert die Zugangsdaten in der Datenbank. | + +Hash selbst erzeugen: + +```bash +php -r "echo password_hash('dein-passwort', PASSWORD_DEFAULT), PHP_EOL;" +# oder im Container: +docker compose exec app php -r "echo password_hash('dein-passwort', PASSWORD_DEFAULT), PHP_EOL;" +``` + +Ist `ADMIN_PASSWORD_HASH` gesetzt, gewinnt dieser Wert gegenüber der Datenbank – +das ist gleichzeitig der Weg, ein vergessenes Passwort zurückzusetzen. + +### Sessions und Login-Schutz + +| Schlüssel | Standard | Bedeutung | +|---|---|---| +| `SESSION_NAME` | `urlshort_session` | Name des Session-Cookies | +| `SESSION_LIFETIME` | `7200` | Sekunden bis zum automatischen Abmelden bei Inaktivität | +| `SESSION_ABSOLUTE_LIFETIME` | `43200` | Harte Obergrenze je Sitzung, unabhängig von Aktivität (`0` = aus) | +| `SESSION_SECURE` | `false` | Cookie nur über HTTPS. Wird bei einer HTTPS-Anfrage automatisch aktiviert; auf `true` setzen, wenn ausschließlich HTTPS verwendet wird | +| `LOGIN_MAX_ATTEMPTS` | `5` | Fehlversuche bis zur Sperre | +| `LOGIN_LOCKOUT_SECONDS` | `900` | Dauer der Sperre | +| `TRUSTED_PROXIES` | *(leer)* | Kommaliste aus IPs/CIDR-Bereichen, deren `X-Forwarded-*`-Header ausgewertet werden dürfen. **Leer lassen, wenn kein Proxy davorsteht** – sonst kann jeder die Client-IP fälschen und die Login-Sperre umgehen | +| `HSTS_MAX_AGE` | `31536000` | HSTS-Dauer in Sekunden (`0` = aus). Wird nur bei HTTPS-Anfragen gesendet | + +> **Hinter Reverse Proxy, Cloudflare oder Loadbalancer:** ohne `TRUSTED_PROXIES` sieht +> die Anwendung nur die IP des Proxys – die Login-Sperre gilt dann für alle Besucher +> gemeinsam. Dort die IP des Proxys eintragen (bei Docker Compose z. B. das +> Bridge-Netz `172.16.0.0/12`), aber **nur** die tatsächlich vorgeschalteten Adressen. + +### Datenschutz + +| Schlüssel | Standard | Bedeutung | +|---|---|---| +| `TRACK_IP` | `none` | `none` = IP wird nirgends verarbeitet oder gespeichert. `hash` = statt der IP wird ein gesalzener, tageweise rotierender 128-Bit-Hash abgelegt. | +| `IP_HASH_SALT` | *(leer)* | Zufälliger Salt, nur bei `TRACK_IP=hash` relevant. Ohne Salt bleibt der Hash leer. | +| `VISIT_RETENTION_DAYS` | `365` | Einzelne Besuchsdatensätze älter als X Tage werden automatisch gelöscht (`0` = nie). Die Gesamt-Klickzahl am Link bleibt erhalten. | + +### Uploads + +| Schlüssel | Standard | Bedeutung | +|---|---|---| +| `UPLOAD_DIR` | `data/uploads` | Ablage der QR-Logos (außerhalb des Webroots) | +| `UPLOAD_MAX_BYTES` | `2097152` | Maximale Logogröße in Byte (2 MB) | + +--- + +## Bedienung + +### Anmelden + +Beim ersten Aufruf erscheint der Setup-Assistent (sofern kein +`ADMIN_PASSWORD_HASH` gesetzt ist). Danach führt jeder Aufruf zur Anmeldemaske. + +### Kurzlink anlegen + +**Kurzlinks → Neuer Kurzlink** + +- **Ziel-URL** – fehlt das Schema, wird `https://` ergänzt. + + Erlaubt sind nur `http` und `https` (`javascript:`, `data:`, `file:`, `mailto:` werden abgelehnt). + Innerhalb dieser Schemata ist die Prüfung bewusst großzügig: + + | Beispiel | | + |---|---| + | `https://müller.de/preise` | Umlaut-Domains (IDN) | + | `https://de.wikipedia.org/wiki/München` | Umlaute in Pfad und Query | + | `http://intranet/wiki`, `http://nas:5000/` | interne Hostnamen ohne Punkt | + | `https://[2001:db8::1]:8443/x` | IPv6-Adressen | + | `https://user:pass@beispiel.de/` | Benutzerinfo | + + Gespeichert und angezeigt wird die **lesbare** Schreibweise. Erst beim Weiterleiten wird der + Hostname nach Punycode gewandelt (`müller.de` → `xn--mller-kva.de`), weil HTTP-Header laut + RFC 7230 ASCII sein müssen. Pfad und Query werden beim Speichern prozentkodiert, ohne + bereits kodierte Sequenzen ein zweites Mal zu kodieren. +- **Wunsch-Slug** – optional. Leer lassen für einen zufälligen Slug. Eingaben werden + normalisiert (Großbuchstaben und Umlaute werden umgesetzt, Sonderzeichen zu + Bindestrichen) und auf Eindeutigkeit geprüft. +- **Interne Bezeichnung** – nur zur eigenen Orientierung, nirgends öffentlich. +- **Aktiv** – deaktivierte Links liefern die 404-Seite, behalten aber ihre Statistik. + +Nach dem Speichern landest du direkt beim QR-Code-Generator. + +### QR-Code gestalten + +- Farben per Farbwähler, Hex-Feld oder Voreinstellung. Bei zu geringem Kontrast oder + invertierter Darstellung erscheint sofort eine Warnung – solche Codes werden von + vielen Scannern nicht erkannt. +- **Größe** betrifft den PNG-Download; SVG skaliert ohnehin verlustfrei. +- **Randbreite** ist die Quiet Zone in Modulen. Die Spezifikation empfiehlt 4. +- **Logo** – PNG, JPG oder SVG. Die Fehlerkorrektur springt automatisch auf Level H, + damit der Code trotz Abdeckung lesbar bleibt. Als Faustregel: nicht mehr als + ~25 % der Breite und den fertigen Code immer mit einem echten Gerät testen. +- **Speichern** ist optional – Vorschau und Download nutzen immer die aktuell + eingestellten Werte. Gespeichert wird nur, damit die Einstellungen beim nächsten + Besuch wieder da sind. + +> Der QR-Code zeigt auf den **Kurzlink**, nicht auf die Ziel-URL. Gedruckte Codes +> bleiben dadurch gültig, auch wenn sich das Ziel später ändert. + +### Mehrere Domains + +Standardmäßig gibt es genau eine Domain – sie wird beim ersten Start automatisch aus +`APP_BASE_URL` angelegt. Wer Kurzlinks unter mehreren Domains ausliefern will +(z. B. `short.example.de` für interne Links und `qr.example.de` für gedruckte QR-Codes), +trägt sie unter **Domains → Neue Domain** ein. + +Danach steht im Kurzlink-Formular eine Domain-Auswahl. Der Slug muss nur noch **innerhalb +seiner Domain** eindeutig sein – `short.example.de/messe` und `qr.example.de/messe` sind +zwei unabhängige Links mit eigener Statistik und eigenem QR-Code. + +#### Was beim Provider zu tun ist + +Eine Domain hier einzutragen genügt nicht – sie muss beim Domain-Anbieter und beim Hoster +auf **diese** Installation zeigen. Dieselbe Anleitung steht auch im Admin-Bereich unter +**Domains**. + +> **Wichtig:** Die neue Domain darf *keine* zweite Installation bekommen und *keine* +> „Domain-Weiterleitung“ des Registrars sein. Eine Weiterleitung ersetzt die Adresse im +> Browser – der Slug ginge dabei verloren. Die Domain muss vom **selben Webroot** +> beantwortet werden. + +**1. DNS: Domain auf den Server zeigen lassen** + +Beim Domain-Anbieter in der DNS- bzw. Zonenverwaltung anlegen: + +| Typ | Name | Wert | Wofür | +|---|---|---|---| +| `A` | `qr` | `203.0.113.10` | IPv4 des Servers | +| `AAAA` | `qr` | `2001:db8::10` | IPv6, falls vorhanden | +| `CNAME` | `qr` | `short.example.de.` | Alternative zu A/AAAA | + +Entweder A (+ AAAA) *oder* CNAME – nicht beides. Die richtige IP steht im Hosting-Panel +oder im vorhandenen Eintrag der Hauptdomain. Je nach TTL dauert die Verteilung wenige +Minuten bis einige Stunden. + +Die Menüs heißen je nach Anbieter anders, gemeint ist immer dasselbe: +IONOS „Domains & SSL → Domain → DNS“ · Strato „Domainverwaltung → DNS-Einstellungen“ · +Netcup „Domains → DNS“ · Hetzner „DNS Console → Zone → Records“ · +Cloudflare „DNS → Records“ · All-Inkl „Domains → Einstellungen → DNS“. +Liegen Domain und Hosting beim selben Anbieter, wird der Eintrag beim Anlegen der +Subdomain oft automatisch gesetzt – dann entfällt dieser Schritt. + +**2. Hosting: Domain auf dasselbe Verzeichnis legen** + +- **Plesk:** „Websites & Domains → Subdomain hinzufügen“. Den vorgeschlagenen + **Dokumentenstamm** auf das Verzeichnis der vorhandenen Installation ändern (dort, wo + `index.php` liegt – meist `…/httpdocs/public`). Kein neues Hosting-Paket anlegen. +- **cPanel:** „Domains → Create A New Domain“, Haken bei *„Share document root“* setzen + bzw. den **Document Root** manuell auf das vorhandene Verzeichnis zeigen lassen. +- **Anderes Panel (IONOS, Strato, All-Inkl …):** „Subdomain anlegen“ bzw. „Domain + zuordnen“ mit frei wählbarem **Zielverzeichnis**. Manche nennen das „Alias-“ oder + „Addon-Domain“. Nicht geeignet: „Domain-Weiterleitung“ oder „Frame-Weiterleitung“. +- **Eigener Apache:** + + ```apache + + ServerName short.example.de + ServerAlias qr.example.de + DocumentRoot /var/www/urlshort/public + + ``` + + ```bash + sudo apachectl configtest && sudo systemctl reload apache2 + ``` + +- **Eigener Nginx:** + + ```nginx + server { + server_name short.example.de qr.example.de; + root /var/www/urlshort/public; + } + ``` + + ```bash + sudo nginx -t && sudo systemctl reload nginx + ``` + +- **Docker:** nichts zu tun – der Container beantwortet jeden Host. Steht ein Reverse + Proxy davor (Traefik, Nginx Proxy Manager, Caddy …), muss die Domain dort als weiterer + Host auf denselben Dienst zeigen. + +**3. HTTPS: Zertifikat für die neue Domain** + +Im Hosting-Panel bei der neuen (Sub-)Domain „Let’s Encrypt“ bzw. „SSL/TLS“ aktivieren – +cPanel erledigt das über AutoSSL meist selbst. Auf eigenem Server: + +```bash +sudo certbot --expand -d short.example.de -d qr.example.de +``` + +Solange kein Zertifikat vorhanden ist, die Domain mit `http://` eintragen – sonst zeigen +Kurzlinks und QR-Codes auf eine Adresse, die der Browser nicht öffnen kann. + +**4. Domain in der Anwendung eintragen** + +Unter **Domains → Neue Domain**, exakt so, wie sie im Browser aufgerufen wird (gleiches +Schema, gleicher Port). Erst danach ordnet die Anwendung Aufrufe dieser Domain zu. + +**5. Prüfen** + +In der Domain-Liste gibt es je Eintrag einen Knopf **Prüfen**. Die Anwendung +löst den Hostnamen auf und ruft die Domain anschließend **von außen** auf – das Ergebnis +zeigt Schritt für Schritt, wie weit die Einrichtung ist: + +| Prüfschritt | Was er belegt | +|---|---| +| **DNS** | Der Hostname löst auf – und auf welche IP-Adresse(n) | +| **Zuordnung** | Der Aufruf landet bei **dieser** Installation (nicht nur „irgendwo“) | +| **HTTPS** | Das Zertifikat gilt für diese Domain | + +Für die Zuordnung wird ein Endpunkt unter `/.well-known/urlshort-check` aufgerufen. Er ist +nur mit einem kurzlebigen, signierten Token ansprechbar (Gültigkeit 3 Minuten) und +antwortet sonst mit `404`. Kommt der Aufruf mit gültigem Token an, ist damit bewiesen, dass +die Domain hier ankommt – nur diese Installation kann ein solches Token erzeugen. + +> Eine reine DNS-Prüfung würde hier nicht reichen: Ein korrekter A-Record sagt nichts +> darüber aus, ob der Webserver die Domain auch dem richtigen Verzeichnis zuordnet – und +> genau das ist der häufigste Fehler. + +Typische Ergebnisse und ihre Ursache: + +| Meldung | Ursache | +|---|---| +| „lässt sich nicht auflösen“ | DNS-Eintrag fehlt oder ist noch nicht verteilt → Schritt 1 | +| „antwortet mit einer Weiterleitung“ | Domain-Weiterleitung beim Anbieter statt echter Zuordnung → Schritt 2 | +| „Antwort kommt nicht von dieser Installation“ | Die Domain zeigt auf ein anderes Verzeichnis oder eine zweite Installation → Schritt 2 | +| „Zertifikat gilt nicht für diese Domain“ | → Schritt 3 | +| „konnte nicht abgerufen werden“ | Webserver antwortet nicht auf diese Domain, Firewall oder Tippfehler | + +**Wenn die Prüfung fehlschlägt, obwohl alles funktioniert:** Manche Server können ihren +eigenen öffentlichen Hostnamen nicht aufrufen (fehlendes NAT-Hairpinning). Lässt sich die +Domain von außen – etwa vom Handy über Mobilfunk – öffnen, ist alles in Ordnung. Die +Anwendung weist im Ergebnis selbst darauf hin. + +Fehlen auf dem Server sowohl `ext-curl` als auch `allow_url_fopen`, kann die Zuordnung +nicht automatisch geprüft werden; die Prüfung sagt das dann ausdrücklich. + +**Zuordnung im Detail:** Die Anwendung vergleicht den `Host`-Header der Anfrage mit den +eingetragenen Domains (Groß-/Kleinschreibung egal, Standardports `:80`/`:443` werden +ignoriert). Passt kein Eintrag – etwa beim Aufruf über die nackte IP-Adresse oder hinter +einem Proxy – greift die **Standarddomain**. Bestehende Links bleiben dadurch auch dann +erreichbar, wenn sich am Hostnamen etwas ändert. + +Die Standarddomain kann nicht gelöscht werden; eine Domain mit Kurzlinks ebenfalls nicht, +solange noch Links daran hängen. Ein Link lässt sich jederzeit über **Bearbeiten** auf eine +andere Domain umziehen – bereits verteilte Links und gedruckte QR-Codes verlieren dabei +allerdings ihre Gültigkeit. + +### Statistik lesen + +**Kurzlinks → Statistik**: Gesamtklicks, Verlauf als Liniendiagramm (Zeitraum oben +rechts umschaltbar), Top-Referrer sowie die Aufteilung nach Gerät, Browser und +Plattform. Darunter die letzten 25 Aufrufe. + +Steht `REDIRECT_STATUS` auf `301`, cachen Browser die Weiterleitung – wiederholte +Aufrufe derselben Person tauchen dann nicht in der Statistik auf. Für belastbare +Zahlen `302` verwenden. + +--- + +## Projektstruktur + +``` +urlshort-qr-gen/ +├── public/ ← Webroot +│ ├── index.php ← einziger Einstiegspunkt +│ ├── .htaccess ← Rewrites + Security-Header +│ └── assets/ ← CSS, JS, Favicon (kein Build-Schritt) +├── src/ +│ ├── App.php ← Verdrahtung, Routing, Security-Header +│ ├── Config.php ← .env + Umgebungsvariablen +│ ├── Database.php ← PDO (SQLite/MySQL) +│ ├── Migrator.php ← führt migrations/ automatisch aus +│ ├── View.php ← minimale Template-Engine +│ ├── Http/ ← Request, Response, Router, BasePath +│ ├── Controller/ ← Setup, Auth, Dashboard, Link, Domain, Stats, Qr, Redirect +│ ├── Repository/ ← Datenzugriff (vorbereitete Statements) +│ ├── Security/ ← Session, Auth, CSRF, Rate-Limiting +│ ├── Service/ ← DomainChecker (DNS- und Zuordnungsprüfung) +│ ├── Qr/ ← QrOptions, QrRenderer, LogoStore +│ └── Support/ ← Slug, UrlValidator, Punycode, ShortDomain, UserAgentParser, Color +├── templates/ ← serverseitig gerenderte Seiten +├── migrations/ ← Schema je Treiber +├── data/ ← SQLite-Datei + hochgeladene Logos (beschreibbar!) +├── tests/ ← PHPUnit +├── docker/ ← php.ini, entrypoint.sh +├── Dockerfile +├── docker-compose.yml +├── .htaccess ← nur für Shared Hosting ohne eigenen Webroot +└── .env.example +``` + +--- + +## Sicherheit und Datenschutz + +- **Nur Weiterleitungen sind öffentlich.** Jede andere Route verlangt eine gültige + Session; hochgeladene Logos werden ebenfalls nur nach Login ausgeliefert. +- **Ersteinrichtung:** Der Setup-Assistent verlangt zusätzlich ein Einmal-Token aus + `data/setup-token.txt` (steht auch im Serverprotokoll). Damit kann eine frisch + hochgeladene Installation nicht von jemandem übernommen werden, der sie zufällig + zuerst aufruft. Nach der Einrichtung wird die Datei gelöscht. +- **Datenbank:** ausschließlich vorbereitete Statements; Spalten- und + Sortiernamen kommen aus festen Allowlists. +- **XSS:** jede Ausgabe läuft durch `htmlspecialchars` mit `ENT_QUOTES`. +- **CSRF:** Synchronizer-Token in jedem Formular; ungültige Token ergeben 403. +- **Sessions:** `HttpOnly`, `SameSite=Lax`, `use_strict_mode`, ID-Wechsel beim Login, + Inaktivitätsgrenze **und** absolute Obergrenze. Über HTTPS wird das Cookie + automatisch auf `secure` gesetzt. Ein Passwortwechsel entwertet bestehende Sessions. +- **Login:** bcrypt, Fehlversuche werden pro IP-*Hash* gezählt und gesperrt. Dieselbe + Sperre gilt für das Einrichtungs-Token. +- **Proxy-Header:** `X-Forwarded-For` und `X-Forwarded-Proto` werden **nur** hinter + einem in `TRUSTED_PROXIES` eingetragenen Proxy ausgewertet. Ohne diese Einstellung + zählt allein `REMOTE_ADDR` – sonst ließe sich die Login-Sperre durch einen + erfundenen Header umgehen. +- **Host-Header:** wird gegen ein Muster geprüft, bevor er in erzeugte URLs einfließt + (und nur, wenn `APP_BASE_URL` nichts vorgibt). +- **Security-Header:** CSP ohne `unsafe-inline` für Skripte, `X-Content-Type-Options`, + `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, + `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`; über HTTPS zusätzlich + HSTS und `upgrade-insecure-requests`. +- **Externe Skripte:** Chart.js ist auf eine Version festgelegt und per **Subresource + Integrity** an seinen Hash gebunden – ein manipuliertes CDN könnte keinen fremden + Code einschleusen. +- **Uploads:** nur PNG/JPG/SVG, Prüfung gegen GD (beschädigte Dateien werden + abgelehnt), Größen- und Abmessungsgrenzen, eigener Dateiname, Ablage außerhalb des + Webroots. SVGs werden beim Speichern von Skripten, Event-Handlern, Entities (XXE), + Stylesheet-Anweisungen und allen externen Referenzen befreit; beim Ausliefern + greift zusätzlich eine abschottende CSP (`sandbox`), sodass ein durchgerutschtes + SVG im Browser trotzdem keinen Code ausführen könnte. +- **Domain-Prüfung:** ruft nur einen festen Pfad ab, folgt keinen Weiterleitungen, + begrenzt die Antwortgröße und verweigert link-lokale Adressen (`169.254.0.0/16`, + `fe80::/10`) – dort liegen bei Cloud-Anbietern die Metadaten-Dienste. +- **DSGVO:** IP-Adressen werden standardmäßig **nicht** gespeichert. Vom User-Agent + bleiben nur Gerätetyp, Browser und Plattform als Kategorien übrig; der Rohstring + wird zwar abgelegt, aber nur gekürzt und über `VISIT_RETENTION_DAYS` automatisch + wieder gelöscht. Ein Cookie-Banner ist für die Weiterleitungen nicht nötig, da + dort keine Cookies gesetzt werden. + +Das einzige Fremd-CDN ist Chart.js (`cdn.jsdelivr.net`) für das Liniendiagramm im +Admin-Bereich. Ist es nicht erreichbar, zeichnet die Anwendung ein einfaches +Balkendiagramm selbst. Wer ganz ohne CDN auskommen will, legt +`chart.umd.min.js` unter `public/assets/` ab und passt die URL in +`public/assets/app.js` an. + +--- + +## Tests + +```bash +composer install +vendor/bin/phpunit +``` + +Ohne lokales PHP über Docker: + +```bash +docker run --rm -v "$PWD:/app" -w /app urlshort-qr-gen:latest vendor/bin/phpunit +``` + +Abgedeckt sind die Kernlogik und die Weiterleitung: + +- `SlugTest` – Zufallserzeugung, Kollisionsbehandlung, Normalisierung, Validierung +- `UrlValidatorTest` – Schema-Prüfung, Normalisierung, IDN, Prozentkodierung, abgelehnte Protokolle +- `PunycodeTest` – IDN-Kodierung, gegengeprüft gegen `idn_to_ascii` +- `ShortDomainTest` – Basis-URL und Host-Header müssen zueinander passen +- `CheckTokenTest` – Signatur, Ablauf und Manipulationsschutz der Domain-Prüfung +- `DomainCheckerTest` – Prüfergebnisse für nicht auflösbare und nicht erreichbare Domains +- `UserAgentParserTest` – Gerätetyp, Browser, Plattform +- `ColorTest` – Kontrastberechnung und Warnungen +- `QrRendererTest` – PNG-/SVG-Erzeugung, Größen, Logos, Level-H-Automatik +- `LogoStoreTest` – Upload-Prüfung, SVG-Bereinigung, Pfad-Traversal +- `RedirectTest` – Migrationen, Redirect-Status, 404, Klickzählung, Datensparsamkeit, + Domain-Auflösung über den `Host`-Header +- `MigrationUpgradeTest` – Update einer bestehenden Installation auf mehrere Domains + +--- + +## Fehlerbehebung + +**„Abhängigkeiten fehlen“ nach dem Upload** +`vendor/` wurde nicht mit hochgeladen. FTP-Clients überspringen große +Verzeichnisse gerne bei Abbrüchen – Upload wiederholen. + +**Kurzlinks liefern 404, die Verwaltung funktioniert aber** +`mod_rewrite` ist nicht aktiv oder `AllowOverride` verbietet die `.htaccess`. +Beim Hoster aktivieren lassen. + +**„Datenbankverzeichnis ist nicht beschreibbar“** +Schreibrechte für `data/` setzen (`755` oder `775`, Eigentümer = Webserver-Benutzer). + +**Kurzlinks zeigen die falsche Domain** +`APP_BASE_URL` in der `.env` korrigieren (ohne Schrägstrich am Ende). + +**Passwort vergessen** +`ADMIN_USER` und `ADMIN_PASSWORD_HASH` in der `.env` setzen – diese Werte haben +Vorrang vor der Datenbank. + +**SVG-Logo fehlt im PNG-Download** +Für das Rastern von SVGs wird `imagick` benötigt. Ist die Erweiterung nicht +verfügbar, stattdessen ein PNG-Logo hochladen oder den SVG-Download nutzen. + +**Statistik zählt zu wenig** +`REDIRECT_STATUS=301` lässt Browser die Weiterleitung cachen. Auf `302` umstellen. + +--- + +## Lizenz + +MIT