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.
This commit is contained in:
2026-08-03 18:11:40 +02:00
parent f4aece9557
commit 7d5a4f3f29
+668
View File
@@ -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: <http://localhost:8080> → 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
<VirtualHost *:443>
ServerName short.example.de
ServerAlias qr.example.de
DocumentRoot /var/www/urlshort/public
</VirtualHost>
```
```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