Files
urlshort-qr-gen/README.md
T
florian.wilde 7d5a4f3f29 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.
2026-08-03 18:11:40 +02:00

669 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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