Wer bereits gedruckte QR-Codes aus einem anderen System uebernimmt, kann deren Adressen nicht mehr aendern. Sehen die aus wie "/g/1AEJ0X90ZmF", waren sie hier bisher nicht abbildbar: der Normalisierer machte daraus "g-1aej0x90zmf" - Schraegstrich zu Bindestrich, alles klein. Der Kurzlink zeigte damit ins Leere. Ein Slug darf jetzt mehrere durch "/" getrennte Abschnitte haben (hoechstens acht, je 64 Zeichen) und behaelt die eingegebene Schreibweise. Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich und Punkt; jeder Abschnitt muss mit einem Buchstaben oder einer Ziffer beginnen und enden. Umlaute und Leerzeichen werden weiterhin umgeschrieben. Wird versehentlich eine komplette URL eingefuegt, bleibt davon nur der Pfad uebrig. Damit die Verwaltung nicht verdeckt werden kann, greift die Liste reservierter Namen jetzt auf den ERSTEN Abschnitt: "admin/geheim" wird ebenso abgelehnt wie "admin". Die Route der Weiterleitung steht weiterhin ganz am Ende, alle Verwaltungsrouten gewinnen davor. Bei der Eindeutigkeit gilt bewusst das Gegenteil: sie wird OHNE Ruecksicht auf Gross- und Kleinschreibung geprueft. "Messe" und "messe" koennen auf derselben Domain nicht nebeneinander bestehen - an einer gedruckten Adresse waere sonst nicht mehr erkennbar, welcher Link gemeint ist. Ausserdem wuerde MySQL sie je nach Kollation ohnehin als gleich ansehen, SQLite nicht; so verhalten sich beide Treiber wieder gleich. Beim Aufruf wird zuerst exakt gesucht und nur bei einem Fehlschlag ohne Ruecksicht auf die Schreibweise - abgetippte Codes stimmen nicht immer. Weil die Eindeutigkeit oben schon ohne Ruecksicht geprueft wurde, kann dieser zweite Versuch nie mehrdeutig sein. Geprueft in beiden Betriebsarten: im Container und in der Shared-Hosting-Variante, bei der die Root-.htaccess nach public/ umleitet - dort sind mehrteilige Pfade der wahrscheinlichste Stolperstein.
36 KiB
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
- Systemvoraussetzungen
- Deployment A: Shared Webhosting per FTP
- Deployment B: Docker
- Konfigurationsreferenz
- Bedienung
- Projektstruktur
- Sicherheit und Datenschutz
- Tests
- Fehlerbehebung
Funktionsumfang
Kurzlinks
- Mehrere Domains auf einer Installation:
short.example.de/messeundqr.example.de/messekö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 - Mehrteilige Slugs mit Schrägstrich (
g/1AEJ0X90ZmF) und erhaltener Groß-/Kleinschreibung – damit lassen sich bereits gedruckte QR-Codes aus einem anderen System übernehmen - URL-Validierung: ausschließlich
httpundhttps– 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)
- 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
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):
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.
-
Kompletten Projektordner per FTP/SFTP hochladen, z. B. nach
/kurzlinks. -
Im Hosting-Panel den Webroot der Domain auf
/kurzlinks/publicsetzen. -
Schreibrechte für den Ordner
data/setzen (meist755, bei manchen Hostern775). -
.env.examplenach.envkopieren und mindestensAPP_BASE_URLeintragen:APP_BASE_URL=https://kurz.deine-domain.de -
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:
- Den gesamten Projektinhalt (inklusive
public/,src/,vendor/,data/, der beiliegenden.htaccessim Stammverzeichnis und der.env) direkt in den Webroot hochladen. - Schreibrechte für
data/setzen. .envanlegen wie oben.- 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/.envundhttps://deine-domain.de/data/database.sqliteauf. Beides muss403oder404liefern. Falls nicht, unterstützt der HosterAllowOverridenicht 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.
-
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.
-
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. -
Hochladen per SFTP (Zugangsdaten stehen in konsoleH unter Benutzer). Den Inhalt von
build/urlshort-qr-gen-release/übertragen, nicht den Ordner selbst. -
Schreibrechte für
data/setzen – im FTP-Programm Rechte755(bei manchen Tarifen775). Ohne das legt die Anwendung keine Datenbank an. -
.envanlegen:.env.examplein.envumbenennen und mindestens eintragen:APP_BASE_URL=https://kurz.deine-domain.de SESSION_SECURE=true -
HTTPS aktivieren – Hetzner stellt kostenlose Let’s-Encrypt-Zertifikate aus (Einstellungen → SSL). Vorher
APP_BASE_URLaufhttp://lassen, sonst zeigen Kurzlinks auf eine Adresse, die der Browser nicht öffnet. -
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_rewritemuss 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_URLmuss dannhttps://domain.de/kurzlauten. - Die SQLite-Datei liegt in
data/database.sqliteund 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)
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:
APP_PORT=9000 APP_BASE_URL=http://localhost:9000 docker compose up -d --build
Mit MariaDB statt SQLite
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.ymlbzw. einer.envneben der Compose-Datei (Compose setzt sie als Container-Umgebung), oder - eine in den Container gemountete
.env-Datei.
Achtung: Eine im Projekt vorhandene
.envhat Vorrang vor Container-Umgebungsvariablen. Für Docker also entweder das eine oder das andere verwenden – nicht beides gemischt.
Nützliche Befehle
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:
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_PROXIESsieht 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-Netz172.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
httpundhttps(javascript:,data:,file:,mailto:werden abgelehnt). Innerhalb dieser Schemata ist die Prüfung bewusst großzügig:Beispiel https://müller.de/preiseUmlaut-Domains (IDN) https://de.wikipedia.org/wiki/MünchenUmlaute in Pfad und Query http://intranet/wiki,http://nas:5000/interne Hostnamen ohne Punkt https://[2001:db8::1]:8443/xIPv6-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.
Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich und Punkt. Der Schrägstrich trennt Abschnitte, sodass sich auch mehrteilige Adressen abbilden lassen:
Eingabe Ergebnis messe2026deine-domain.de/messe2026g/1AEJ0X90ZmFdeine-domain.de/g/1AEJ0X90ZmFFlyer2026/Halle-3deine-domain.de/Flyer2026/Halle-3https://alt.de/g/ABCwird auf g/ABCreduziertGroß- und Kleinschreibung bleibt erhalten. Das ist wichtig, um bereits gedruckte QR-Codes aus einem anderen System zu übernehmen – deren Pfade lassen sich nachträglich nicht mehr ändern. Beim Aufruf wird die Schreibweise zusätzlich ohne Beachtung erkannt, falls jemand den Code abtippt.
Zwei Links, die sich nur in der Schreibweise unterscheiden, sind auf derselben Domain nicht möglich (
Messeundmesseschließen sich aus) – sonst wäre an einer gedruckten Adresse nicht mehr erkennbar, welcher gemeint ist.Umlaute und Leerzeichen werden weiterhin umgeschrieben (
Grüße 2026→Gruesse-2026). Der erste Abschnitt darf nichtadmin,login,assets,.well-knownusw. lauten, damit die Verwaltung nicht verdeckt wird. -
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.phpliegt – 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:
<VirtualHost *:443> ServerName short.example.de ServerAlias qr.example.de DocumentRoot /var/www/urlshort/public </VirtualHost>sudo apachectl configtest && sudo systemctl reload apache2 -
Eigener Nginx:
server { server_name short.example.de qr.example.de; root /var/www/urlshort/public; }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:
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
htmlspecialcharsmitENT_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 aufsecuregesetzt. 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-ForundX-Forwarded-Protowerden nur hinter einem inTRUSTED_PROXIESeingetragenen Proxy ausgewertet. Ohne diese Einstellung zählt alleinREMOTE_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_URLnichts vorgibt). - Security-Header: CSP ohne
unsafe-inlinefü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 undupgrade-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_DAYSautomatisch 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
composer install
vendor/bin/phpunit
Ohne lokales PHP über Docker:
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, ValidierungUrlValidatorTest– Schema-Prüfung, Normalisierung, IDN, Prozentkodierung, abgelehnte ProtokollePunycodeTest– IDN-Kodierung, gegengeprüft gegenidn_to_asciiShortDomainTest– Basis-URL und Host-Header müssen zueinander passenCheckTokenTest– Signatur, Ablauf und Manipulationsschutz der Domain-PrüfungDomainCheckerTest– Prüfergebnisse für nicht auflösbare und nicht erreichbare DomainsUserAgentParserTest– Gerätetyp, Browser, PlattformColorTest– Kontrastberechnung und WarnungenQrRendererTest– PNG-/SVG-Erzeugung, Größen, Logos, Level-H-AutomatikLogoStoreTest– Upload-Prüfung, SVG-Bereinigung, Pfad-TraversalRedirectTest– Migrationen, Redirect-Status, 404, Klickzählung, Datensparsamkeit, Domain-Auflösung über denHost-HeaderMigrationUpgradeTest– 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