Files
urlshort-qr-gen/README.md
T
florian.wildeandClaude Opus 5 85b9576b38 test(auth): Gruppenisolation absichern, Doku zum Rechtemodell
AccessControlTest prueft die Trennung ueber die echte Kette
Auth -> AccessPolicy -> Repository, also genau so, wie die
Controller sie benutzen - nicht gegen eine nachgebaute Logik.
Abgedeckt sind unter anderem: ein Mitglied sieht nur die eigene
Gruppe, der direkte Aufruf einer fremden Kennung findet nichts,
ein Benutzer ohne Gruppe sieht gar nichts (die leere Liste darf
nicht wie "keine Einschraenkung" wirken), Viewer duerfen lesen
aber nicht schreiben, das Umhaengen einer Domain nimmt ihre
Kurzlinks mit, fremde Logos werden nicht ausgeliefert, ein
gesperrtes Konto verliert seine laufende Sitzung und ein
Passwortwechsel macht bestehende Sitzungen ungueltig.

MigrationUpgradeTest deckt zusaetzlich den Umstieg ab: das
Bestandskonto wird mit unveraendertem Hash uebernommen, die alten
settings-Schluessel verschwinden, Bestandsdomains landen in der
Standardgruppe - und eine frische Installation bekommt kein
erfundenes Konto.

README: neuer Abschnitt "Benutzer, Gruppen und Rechte" mit dem
Grundprinzip, den beiden Rollenebenen, der Einrichtung in drei
Schritten und dem Umstieg von der Einbenutzer-Version.
ADMIN_USER und ADMIN_PASSWORD_HASH sind als Notfallzugang neu
beschrieben, in README und .env.example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:03:41 +02:00

41 KiB
Raw Blame History

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.

Mehrere Benutzer, in Gruppen organisiert. Eine Domain gehört zu genau einer Gruppe, jeder Kurzlink zu seiner Domain. Wer Mitglied einer Gruppe ist, sieht deren Kurzlinks – als Editor darf er sie auch anlegen und ändern, als Viewer nur ansehen.


Inhalt


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
  • 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 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)

  • 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

Benutzer und Rechte

  • Beliebig viele Konten, verwaltet im Browser – kein Eingriff in Dateien nötig
  • Gruppen bündeln Benutzer und Domains: Mitglieder sehen die Kurzlinks ihrer Gruppen, sonst nichts – auch nicht über eine geratene Adresse
  • Zwei Rollenebenen: global Administrator (Benutzer, Gruppen, Domains, sieht alles) bzw. Benutzer, innerhalb einer Gruppe Editor (anlegen und ändern) bzw. Viewer (nur lesen)
  • Jeder ändert sein Passwort selbst; eine Änderung beendet alle anderen Sitzungen
  • Notfallzugang über die .env, falls das letzte Administratorkonto einmal aussperrt

Sicherheit

  • Setup-Assistent beim ersten Start; Notfallzugang optional in der .env
  • Session-Login mit bcrypt-Hash, CSRF-Token für alle Formulare
  • Rate-Limiting am Login nach IP-Adresse und Benutzername
  • 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.

  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:

    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:

    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)

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.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

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.

Notfallzugang

Die regulären Konten stehen in der Datenbank und werden unter Benutzer verwaltet; das erste legt der Setup-Assistent an. Die beiden folgenden Schlüssel beschreiben davon unabhängig einen zusätzlichen Zugang mit Administratorrechten:

Schlüssel Standard Bedeutung
ADMIN_USER admin Benutzername des Notfallzugangs
ADMIN_PASSWORD_HASH (leer) bcrypt-Hash dazu. Leer → es gibt keinen Notfallzugang.

Dieses Konto taucht in der Benutzerverwaltung nicht auf und lässt sich dort auch nicht ändern – es existiert nur in der .env. Gedacht ist es für den Fall, dass das letzte Administratorkonto gesperrt wurde oder sein Passwort verloren ging. Ein reguläres Konto mit demselben Namen gewinnt bei der Anmeldung; die Benutzerverwaltung verhindert deshalb, dass der Name doppelt vergeben wird.

Hash 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;"

Wird der Zugang nicht mehr gebraucht, beide Zeilen leeren oder entfernen.

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 noch kein Konto existiert und kein ADMIN_PASSWORD_HASH gesetzt ist). Das dort angelegte Konto ist Administrator. Danach führt jeder Aufruf zur Anmeldemaske.

Das eigene Passwort ändert man über den Benutzernamen oben rechts → Mein Konto. Dort steht auch, in welchen Gruppen man mit welcher Rolle Mitglied ist.

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.

    Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich und Punkt. Der Schrägstrich trennt Abschnitte, sodass sich auch mehrteilige Adressen abbilden lassen:

    Eingabe Ergebnis
    messe2026 deine-domain.de/messe2026
    g/1AEJ0X90ZmF deine-domain.de/g/1AEJ0X90ZmF
    Flyer2026/Halle-3 deine-domain.de/Flyer2026/Halle-3
    https://alt.de/g/ABC wird auf g/ABC reduziert

    Groß- 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 (Messe und messe schließ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 nicht admin, login, assets, .well-known usw. 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.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:

    <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.


Benutzer, Gruppen und Rechte

Das Grundprinzip

Der Zugriff hängt an einer einzigen Kette:

Benutzer  →  Gruppe  →  Domain  →  Kurzlink

Ein Benutzer ist Mitglied in beliebig vielen Gruppen. Jede Domain gehört zu genau einer Gruppe, jeder Kurzlink zu seiner Domain. Wer Mitglied einer Gruppe ist, sieht damit alle Kurzlinks aller Domains dieser Gruppe – und sonst nichts.

Ein Kurzlink hat also keinen persönlichen Eigentümer. Das ist Absicht: verlässt jemand das Unternehmen, bleiben die Links seiner Gruppe erreichbar, und gedruckte QR-Codes funktionieren weiter. Das Löschen eines Kontos entfernt deshalb keine Kurzlinks.

Die beiden Rollenebenen

Global (bei jedem Konto unter Benutzer):

Rolle Darf
Administrator alles: Benutzer, Gruppen und Domains verwalten, sieht sämtliche Kurzlinks unabhängig von Gruppen
Benutzer nur das, was die Gruppenmitgliedschaft hergibt

Innerhalb einer Gruppe (je Mitgliedschaft):

Rolle Darf
Editor Kurzlinks der Gruppe anlegen, ändern, deaktivieren, löschen und QR-Codes gestalten
Viewer Kurzlinks ansehen, Statistik lesen, QR-Codes herunterladen – aber nichts speichern

Ein Administrator braucht keine Mitgliedschaft, um Kurzlinks zu sehen. Damit er auch selbst welche anlegen kann, muss er in der betreffenden Gruppe Editor sein – deshalb trägt der Setup-Assistent das erste Konto gleich als Editor der Gruppe „Standard“ ein.

Einrichten in drei Schritten

  1. Gruppen → Neue Gruppe – z. B. „Marketing“.
  2. Domains → Domain bearbeiten – Feld Gruppe auf „Marketing“ stellen. Alle Kurzlinks dieser Domain gehören ab sofort der Gruppe. Wird eine Domain später umgehängt, wandern ihre Kurzlinks mit.
  3. Benutzer → Neuer Benutzer – Konto anlegen und in der Tabelle Gruppen die Rolle je Gruppe setzen (kein Mitglied / Editor / Viewer).

Mitglieder lassen sich auch von der anderen Seite pflegen: Gruppen → Bearbeiten zeigt alle Mitglieder samt Rolle und listet die Domains der Gruppe.

Was passiert, wenn …

Fall Verhalten
Ein Benutzer ruft die Adresse eines fremden Kurzlinks direkt auf 404 – die Existenz wird nicht verraten
Ein Viewer öffnet das Bearbeitungsformular 403 mit Hinweis auf die fehlende Editor-Rolle
Ein Benutzer ohne Gruppe meldet sich an Übersicht und Liste bleiben leer, mit erklärendem Hinweis
Ein Konto wird gesperrt Laufende Sitzungen enden beim nächsten Aufruf sofort
Ein Passwort wird geändert Alle anderen Sitzungen dieses Kontos werden ungültig
Das letzte aktive Administratorkonto soll herabgestuft, gesperrt oder gelöscht werden Wird abgelehnt
Eine Gruppe mit zugeordneten Domains soll gelöscht werden Wird abgelehnt – erst die Domains umhängen

Auch hochgeladene Logos sind an die Gruppe gebunden: /admin/uploads/<datei> liefert nur aus, was zu einem Kurzlink aus einer Gruppe des Aufrufers gehört. Der Dateiname allein öffnet nichts.

Umstieg von der Einbenutzer-Version

Beim ersten Aufruf nach dem Update läuft die Migration automatisch:

  • Das bisherige Konto wird zu Benutzer Nr. 1 mit der Rolle Administrator. Benutzername und Passwort bleiben unverändert – der vorhandene Hash wird übernommen.
  • Es entsteht die Gruppe „Standard“; alle vorhandenen Domains landen darin, das übernommene Konto wird ihr Editor.
  • Die alten Schlüssel admin_user und admin_password_hash werden aus der settings-Tabelle entfernt, damit keine veralteten Zugangsdaten zurückbleiben.

Ein in der .env hinterlegter ADMIN_PASSWORD_HASH bleibt gültig, wird aber ab jetzt als Notfallzugang behandelt: er steht neben den regulären Konten, statt sie zu überstimmen.


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,
│   │                       User, Group, Profile, Redirect
│   ├── Repository/       ← Datenzugriff (vorbereitete Statements)
│   ├── Security/         ← Session, Auth, AccessPolicy, Role, CSRF, Rate-Limiting
│   ├── Service/          ← DomainChecker (DNS- und Zuordnungsprüfung)
│   ├── Qr/               ← QrOptions, QrRenderer, LogoStore
│   └── Support/          ← Slug, UrlValidator, Punycode, ShortDomain,
│                           UserAgentParser, Color, Password, Username
├── 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

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, 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