# 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 – getestet mit **8.3, 8.4 und 8.5** | | 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. ### Upload-Paket erzeugen ```bash sh build-release.sh ``` Ohne lokales PHP über das Anwendungs-Image (der schlanke `composer:2` hat kein `gd` und bricht an der Plattformprüfung ab): ```bash docker compose build docker run --rm -v "$PWD:/app" -w /app urlshort-qr-gen:latest sh build-release.sh ``` Ergebnis ist `build/urlshort-qr-gen-release/` – rund **1 MB, 217 Dateien**. Das Skript installiert die Abhängigkeiten ohne Entwicklungspakete und entfernt eine 16 MB große Schriftart aus `endroid/qr-code`, die nur für Beschriftungen *unter* dem QR-Code gebraucht wird (die diese Anwendung nicht erzeugt). Sollte sich das einmal ändern, merkt das Skript es und behält die Datei. ### Was hochgeladen wird | | | |---|---| | `public/` | Webroot: `index.php`, `.htaccess`, `assets/` | | `src/` | Anwendungscode | | `templates/` | Seiten | | `migrations/` | Datenbankschema | | `vendor/` | Abhängigkeiten (ohne Entwicklungspakete) | | `data/` | leer, muss **beschreibbar** sein – hier entstehen Datenbank und Logos | | `.htaccess` | nur für Variante A2 nötig (siehe unten) | | `.env` | selbst anlegen, Vorlage ist `.env.example` | **Nicht** hochgeladen werden `tests/`, `docker/`, `Dockerfile`, `docker-compose.yml`, `phpunit.xml`, `build-release.sh` und `.git/` – das Release-Paket enthält sie erst gar nicht. ### 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. ### Beispiel: Hetzner Webhosting (konsoleH) Stellvertretend für Anbieter mit eigenem Panel – bei IONOS, Strato, All-Inkl oder Netcup heißen die Menüpunkte anders, die Schritte sind dieselben. 1. **PHP-Version prüfen/setzen** – in konsoleH unter *Einstellungen → PHP-Version* mindestens **PHP 8.2** wählen. Steht dort noch 7.x oder 8.0, läuft die Anwendung nicht. 2. **Verzeichnis wählen.** Der Webspace hat pro Domain ein Verzeichnis unterhalb von `/public_html`. Lässt sich das Zielverzeichnis der Domain frei setzen, nimm **Variante A1** und zeige damit auf `…/kurzlinks/public`. Geht das nicht, nimm **Variante A2** und lade alles direkt in das Domainverzeichnis. 3. **Hochladen** per SFTP (Zugangsdaten stehen in konsoleH unter *Benutzer*). Den **Inhalt** von `build/urlshort-qr-gen-release/` übertragen, nicht den Ordner selbst. 4. **Schreibrechte** für `data/` setzen – im FTP-Programm Rechte `755` (bei manchen Tarifen `775`). Ohne das legt die Anwendung keine Datenbank an. 5. **`.env` anlegen:** `.env.example` in `.env` umbenennen und mindestens eintragen: ```dotenv APP_BASE_URL=https://kurz.deine-domain.de SESSION_SECURE=true ``` 6. **HTTPS aktivieren** – Hetzner stellt kostenlose Let’s-Encrypt-Zertifikate aus (*Einstellungen → SSL*). Vorher `APP_BASE_URL` auf `http://` lassen, sonst zeigen Kurzlinks auf eine Adresse, die der Browser nicht öffnet. 7. **Domain aufrufen.** Der Setup-Assistent prüft zuerst die Systemvoraussetzungen und zeigt an, falls etwas fehlt. Er verlangt außerdem das Einmal-Token aus `data/setup-token.txt` – diese Datei per FTP öffnen, den 16-stelligen Wert eintragen. Danach wird sie automatisch gelöscht. **Datenbank:** SQLite genügt und ist voreingestellt – die bei Hetzner enthaltenen MySQL-Datenbanken werden nicht gebraucht. Wer sie trotzdem nutzen will, trägt in der `.env` `DB_DRIVER=mysql` samt Zugangsdaten aus konsoleH ein. **Was bei Hetzner erfahrungsgemäß passt:** `mod_rewrite`, `gd`, `mbstring`, `fileinfo` und `pdo_sqlite` sind vorhanden. `imagick` ist es meist nicht – dann erscheinen SVG-Logos nur im SVG-Download, PNG-Logos funktionieren normal. Der Setup-Assistent zeigt den tatsächlichen Stand deines Tarifs an; verlass dich darauf statt auf diese Aufzählung. ### 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 ``` Die Suite läuft ohne Deprecation-Meldungen unter PHP 8.3, 8.4 und 8.5. Das ist kein Selbstzweck: Viele Hoster haben `display_errors` erzwungen auf `On`. Eine einzige Meldung würde dort verhindern, dass noch HTTP-Header gesendet werden – keine Session, keine Weiterleitung – und jeden PNG-Download beschädigen. Zusätzlich puffert `public/index.php` die Ausgabe und verwirft Fremdausgaben, bevor die Antwort rausgeht. 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