Auf einem Hetzner-Webhosting mit PHP 8.5 brach die Anwendung beim ersten Aufruf ab. Ausloeser war session.sid_length: seit PHP 8.4 als veraltet gemarkt. Die Meldung wurde ausgegeben, damit waren Header raus - und danach liess sich weder eine ini-Einstellung aendern noch eine Session starten. Aus einer Randnotiz wurde so ein Totalausfall. Behoben auf drei Ebenen: 1. Die veralteten Aufrufe sind weg. session.sid_length und session.sid_bits_per_character werden nicht mehr gesetzt; die Voreinstellungen liefern mit 128 Bit ohnehin genug Entropie. Ebenso entfallen curl_close(), imagedestroy() und finfo_close() - seit PHP 8.0 bzw. 8.5 wirkungslos und als veraltet gemeldet. 2. public/index.php puffert die Ausgabe von der ersten Zeile an. Viele Hoster erzwingen display_errors und lassen sich per ini_set() nicht davon abbringen. Gepuffert laesst sich Fremdausgabe vor dem Senden verwerfen und ins Fehlerprotokoll umleiten, statt die Antwort zu zerstoeren. Das schuetzt auch Binaerdownloads: eine einzelne Meldung haette bisher jedes PNG beschaedigt. Session::start() meldet ausserdem klar, wenn doch schon Ausgaben raus sind, statt eine Kaskade aus Warnungen zu erzeugen. 3. endroid/qr-code von 5.1 auf 6.0 angehoben. Version 5 markiert Parameter implizit als nullable, was PHP 8.4 bemaengelt - bei jedem erzeugten QR-Code. Version 6.1 waere ebenfalls verfuegbar, verlangt aber PHP 8.4+ und wuerde Hoster mit 8.2 oder 8.3 ausschliessen; 6.0 laeuft ab 8.1. Getestet wird jetzt gegen PHP 8.3, 8.4 und 8.5, jeweils mit display_errors=On und error_reporting=E_ALL, damit Deprecations sichtbar werden statt still im Log zu landen. Alle 232 Tests laufen auf allen drei Versionen ohne eine einzige Meldung durch. Zusaetzlich ist das Release-Paket in einer PHP-8.5-Umgebung mit fremdem Webroot geprueft: Ersteinrichtung, Kurzlinks, Weiterleitung, QR-Codes und Statistik, ohne Streuausgabe in Seiten oder Downloads.
741 lines
34 KiB
Markdown
741 lines
34 KiB
Markdown
# 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: <http://localhost:8080> → der Setup-Assistent fragt Benutzername und Passwort ab.
|
||
|
||
Der Container basiert auf `php:8.3-apache` und verwendet dieselbe `.htaccess` wie das
|
||
Shared Hosting – Docker und FTP-Deployment verhalten sich dadurch identisch.
|
||
Datenbank und Logos liegen im benannten Volume `app-data` und überleben ein `--build`.
|
||
|
||
Anderer Port:
|
||
|
||
```bash
|
||
APP_PORT=9000 APP_BASE_URL=http://localhost:9000 docker compose up -d --build
|
||
```
|
||
|
||
### Mit MariaDB statt SQLite
|
||
|
||
```bash
|
||
DB_DRIVER=mysql docker compose --profile mysql up -d --build
|
||
```
|
||
|
||
Die App wartet beim Start, bis die Datenbank erreichbar ist, und legt das Schema
|
||
selbst an.
|
||
|
||
### Konfiguration im Container
|
||
|
||
Die Werte lassen sich auf zwei Wegen setzen:
|
||
|
||
- **Umgebungsvariablen** in `docker-compose.yml` bzw. einer `.env` neben der
|
||
Compose-Datei (Compose setzt sie als Container-Umgebung), **oder**
|
||
- eine in den Container gemountete `.env`-Datei.
|
||
|
||
> Achtung: Eine im Projekt vorhandene `.env` hat **Vorrang** vor
|
||
> Container-Umgebungsvariablen. Für Docker also entweder das eine oder das andere
|
||
> verwenden – nicht beides gemischt.
|
||
|
||
### Nützliche Befehle
|
||
|
||
```bash
|
||
docker compose logs -f app # Logs ansehen
|
||
docker compose exec app sh # Shell im Container
|
||
docker compose down # stoppen (Daten bleiben im Volume)
|
||
docker compose down -v # stoppen und Daten löschen
|
||
```
|
||
|
||
---
|
||
|
||
## Konfigurationsreferenz
|
||
|
||
Alle Werte stehen in der `.env` (Vorlage: `.env.example`) oder als Umgebungsvariablen.
|
||
|
||
### Anwendung
|
||
|
||
| Schlüssel | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `APP_BASE_URL` | *(aus dem Request)* | Basis-URL **ohne** Schrägstrich am Ende. Daraus wird beim ersten Start die Standarddomain angelegt. Weitere Domains werden danach im Admin-Bereich verwaltet – `APP_BASE_URL` dient dann nur noch als Rückfallwert. |
|
||
| `APP_ENV` | `production` | `development` zeigt Fehlerdetails im Browser an. |
|
||
| `APP_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Anzeige. Gespeichert wird immer UTC. |
|
||
| `REDIRECT_STATUS` | `302` | `301`/`308` = permanent (wird vom Browser gecacht → es werden weniger Klicks gezählt), `302`/`307` = temporär (empfohlen). Ungültige Werte fallen auf `302` zurück. |
|
||
|
||
### Datenbank
|
||
|
||
**Standardmäßig SQLite** – eine einzelne Datei unter `data/database.sqlite`, angesprochen
|
||
über PDO. Es ist kein Datenbankserver nötig, nichts einzurichten und nichts zu warten; die
|
||
Datei wird beim ersten Aufruf samt Schema automatisch angelegt. Für einen URL-Shortener ist
|
||
das die passende Wahl: Weiterleitungen sind reine Lesezugriffe über einen Index, und
|
||
Schreibvorgänge (ein `UPDATE` plus ein `INSERT` je Klick) sind selten genug, dass SQLite
|
||
auch bei einigen hunderttausend Klicks im Monat nicht ins Schwitzen kommt. Die Anwendung
|
||
schaltet dafür `WAL` ein, sodass sich Lesen und Schreiben nicht gegenseitig blockieren.
|
||
|
||
**Optional MySQL/MariaDB** – sinnvoll, wenn ohnehin ein Datenbankserver läuft, wenn
|
||
mehrere Webserver auf denselben Datenbestand zugreifen sollen oder wenn die vorhandene
|
||
Backup-Routine auf Datenbanken ausgelegt ist. Umschalten über `DB_DRIVER=mysql`; das
|
||
Schema wird dort ebenso automatisch angelegt.
|
||
|
||
Beide Wege nutzen dieselben vorbereiteten Statements – ein Wechsel ändert nichts an der
|
||
Bedienung. Ein *Umzug* der Daten von SQLite nach MySQL ist allerdings nicht eingebaut;
|
||
dafür bräuchte es einen Export/Import von Hand.
|
||
|
||
Tabellen: `domains`, `links`, `visits`, `qr_designs`, `settings`, `login_attempts`
|
||
sowie `migrations` (welche Migration bereits gelaufen ist).
|
||
|
||
**Backup:** Bei SQLite genügt es, den Ordner `data/` zu sichern – er enthält Datenbank und
|
||
hochgeladene Logos. Am saubersten im laufenden Betrieb mit
|
||
`sqlite3 data/database.sqlite ".backup data/backup.sqlite"`; ein einfaches Kopieren der
|
||
Datei funktioniert in der Praxis ebenfalls, sofern gerade nicht geschrieben wird.
|
||
|
||
| Schlüssel | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `DB_DRIVER` | `sqlite` | `sqlite` oder `mysql` |
|
||
| `DB_SQLITE_PATH` | `data/database.sqlite` | Pfad zur SQLite-Datei, relativ zum Projektstamm. |
|
||
| `DB_HOST` / `DB_PORT` | `127.0.0.1` / `3306` | nur bei `mysql` |
|
||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `urlshort` / `urlshort` / – | nur bei `mysql` |
|
||
| `DB_CHARSET` | `utf8mb4` | nur bei `mysql` |
|
||
|
||
Das Schema wird beim ersten Request automatisch angelegt (`migrations/`), ein
|
||
manueller Migrationsschritt entfällt.
|
||
|
||
### Admin-Zugang
|
||
|
||
| Schlüssel | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `ADMIN_USER` | `admin` | Benutzername |
|
||
| `ADMIN_PASSWORD_HASH` | *(leer)* | bcrypt-Hash. **Leer** → beim ersten Aufruf startet der Setup-Assistent und speichert die Zugangsdaten in der Datenbank. |
|
||
|
||
Hash selbst erzeugen:
|
||
|
||
```bash
|
||
php -r "echo password_hash('dein-passwort', PASSWORD_DEFAULT), PHP_EOL;"
|
||
# oder im Container:
|
||
docker compose exec app php -r "echo password_hash('dein-passwort', PASSWORD_DEFAULT), PHP_EOL;"
|
||
```
|
||
|
||
Ist `ADMIN_PASSWORD_HASH` gesetzt, gewinnt dieser Wert gegenüber der Datenbank –
|
||
das ist gleichzeitig der Weg, ein vergessenes Passwort zurückzusetzen.
|
||
|
||
### Sessions und Login-Schutz
|
||
|
||
| Schlüssel | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `SESSION_NAME` | `urlshort_session` | Name des Session-Cookies |
|
||
| `SESSION_LIFETIME` | `7200` | Sekunden bis zum automatischen Abmelden bei Inaktivität |
|
||
| `SESSION_ABSOLUTE_LIFETIME` | `43200` | Harte Obergrenze je Sitzung, unabhängig von Aktivität (`0` = aus) |
|
||
| `SESSION_SECURE` | `false` | Cookie nur über HTTPS. Wird bei einer HTTPS-Anfrage automatisch aktiviert; auf `true` setzen, wenn ausschließlich HTTPS verwendet wird |
|
||
| `LOGIN_MAX_ATTEMPTS` | `5` | Fehlversuche bis zur Sperre |
|
||
| `LOGIN_LOCKOUT_SECONDS` | `900` | Dauer der Sperre |
|
||
| `TRUSTED_PROXIES` | *(leer)* | Kommaliste aus IPs/CIDR-Bereichen, deren `X-Forwarded-*`-Header ausgewertet werden dürfen. **Leer lassen, wenn kein Proxy davorsteht** – sonst kann jeder die Client-IP fälschen und die Login-Sperre umgehen |
|
||
| `HSTS_MAX_AGE` | `31536000` | HSTS-Dauer in Sekunden (`0` = aus). Wird nur bei HTTPS-Anfragen gesendet |
|
||
|
||
> **Hinter Reverse Proxy, Cloudflare oder Loadbalancer:** ohne `TRUSTED_PROXIES` sieht
|
||
> die Anwendung nur die IP des Proxys – die Login-Sperre gilt dann für alle Besucher
|
||
> gemeinsam. Dort die IP des Proxys eintragen (bei Docker Compose z. B. das
|
||
> Bridge-Netz `172.16.0.0/12`), aber **nur** die tatsächlich vorgeschalteten Adressen.
|
||
|
||
### Datenschutz
|
||
|
||
| Schlüssel | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `TRACK_IP` | `none` | `none` = IP wird nirgends verarbeitet oder gespeichert. `hash` = statt der IP wird ein gesalzener, tageweise rotierender 128-Bit-Hash abgelegt. |
|
||
| `IP_HASH_SALT` | *(leer)* | Zufälliger Salt, nur bei `TRACK_IP=hash` relevant. Ohne Salt bleibt der Hash leer. |
|
||
| `VISIT_RETENTION_DAYS` | `365` | Einzelne Besuchsdatensätze älter als X Tage werden automatisch gelöscht (`0` = nie). Die Gesamt-Klickzahl am Link bleibt erhalten. |
|
||
|
||
### Uploads
|
||
|
||
| Schlüssel | Standard | Bedeutung |
|
||
|---|---|---|
|
||
| `UPLOAD_DIR` | `data/uploads` | Ablage der QR-Logos (außerhalb des Webroots) |
|
||
| `UPLOAD_MAX_BYTES` | `2097152` | Maximale Logogröße in Byte (2 MB) |
|
||
|
||
---
|
||
|
||
## Bedienung
|
||
|
||
### Anmelden
|
||
|
||
Beim ersten Aufruf erscheint der Setup-Assistent (sofern kein
|
||
`ADMIN_PASSWORD_HASH` gesetzt ist). Danach führt jeder Aufruf zur Anmeldemaske.
|
||
|
||
### Kurzlink anlegen
|
||
|
||
**Kurzlinks → Neuer Kurzlink**
|
||
|
||
- **Ziel-URL** – fehlt das Schema, wird `https://` ergänzt.
|
||
|
||
Erlaubt sind nur `http` und `https` (`javascript:`, `data:`, `file:`, `mailto:` werden abgelehnt).
|
||
Innerhalb dieser Schemata ist die Prüfung bewusst großzügig:
|
||
|
||
| Beispiel | |
|
||
|---|---|
|
||
| `https://müller.de/preise` | Umlaut-Domains (IDN) |
|
||
| `https://de.wikipedia.org/wiki/München` | Umlaute in Pfad und Query |
|
||
| `http://intranet/wiki`, `http://nas:5000/` | interne Hostnamen ohne Punkt |
|
||
| `https://[2001:db8::1]:8443/x` | IPv6-Adressen |
|
||
| `https://user:pass@beispiel.de/` | Benutzerinfo |
|
||
|
||
Gespeichert und angezeigt wird die **lesbare** Schreibweise. Erst beim Weiterleiten wird der
|
||
Hostname nach Punycode gewandelt (`müller.de` → `xn--mller-kva.de`), weil HTTP-Header laut
|
||
RFC 7230 ASCII sein müssen. Pfad und Query werden beim Speichern prozentkodiert, ohne
|
||
bereits kodierte Sequenzen ein zweites Mal zu kodieren.
|
||
- **Wunsch-Slug** – optional. Leer lassen für einen zufälligen Slug. Eingaben werden
|
||
normalisiert (Großbuchstaben und Umlaute werden umgesetzt, Sonderzeichen zu
|
||
Bindestrichen) und auf Eindeutigkeit geprüft.
|
||
- **Interne Bezeichnung** – nur zur eigenen Orientierung, nirgends öffentlich.
|
||
- **Aktiv** – deaktivierte Links liefern die 404-Seite, behalten aber ihre Statistik.
|
||
|
||
Nach dem Speichern landest du direkt beim QR-Code-Generator.
|
||
|
||
### QR-Code gestalten
|
||
|
||
- Farben per Farbwähler, Hex-Feld oder Voreinstellung. Bei zu geringem Kontrast oder
|
||
invertierter Darstellung erscheint sofort eine Warnung – solche Codes werden von
|
||
vielen Scannern nicht erkannt.
|
||
- **Größe** betrifft den PNG-Download; SVG skaliert ohnehin verlustfrei.
|
||
- **Randbreite** ist die Quiet Zone in Modulen. Die Spezifikation empfiehlt 4.
|
||
- **Logo** – PNG, JPG oder SVG. Die Fehlerkorrektur springt automatisch auf Level H,
|
||
damit der Code trotz Abdeckung lesbar bleibt. Als Faustregel: nicht mehr als
|
||
~25 % der Breite und den fertigen Code immer mit einem echten Gerät testen.
|
||
- **Speichern** ist optional – Vorschau und Download nutzen immer die aktuell
|
||
eingestellten Werte. Gespeichert wird nur, damit die Einstellungen beim nächsten
|
||
Besuch wieder da sind.
|
||
|
||
> Der QR-Code zeigt auf den **Kurzlink**, nicht auf die Ziel-URL. Gedruckte Codes
|
||
> bleiben dadurch gültig, auch wenn sich das Ziel später ändert.
|
||
|
||
### Mehrere Domains
|
||
|
||
Standardmäßig gibt es genau eine Domain – sie wird beim ersten Start automatisch aus
|
||
`APP_BASE_URL` angelegt. Wer Kurzlinks unter mehreren Domains ausliefern will
|
||
(z. B. `short.example.de` für interne Links und `qr.example.de` für gedruckte QR-Codes),
|
||
trägt sie unter **Domains → Neue Domain** ein.
|
||
|
||
Danach steht im Kurzlink-Formular eine Domain-Auswahl. Der Slug muss nur noch **innerhalb
|
||
seiner Domain** eindeutig sein – `short.example.de/messe` und `qr.example.de/messe` sind
|
||
zwei unabhängige Links mit eigener Statistik und eigenem QR-Code.
|
||
|
||
#### Was beim Provider zu tun ist
|
||
|
||
Eine Domain hier einzutragen genügt nicht – sie muss beim Domain-Anbieter und beim Hoster
|
||
auf **diese** Installation zeigen. Dieselbe Anleitung steht auch im Admin-Bereich unter
|
||
**Domains**.
|
||
|
||
> **Wichtig:** Die neue Domain darf *keine* zweite Installation bekommen und *keine*
|
||
> „Domain-Weiterleitung“ des Registrars sein. Eine Weiterleitung ersetzt die Adresse im
|
||
> Browser – der Slug ginge dabei verloren. Die Domain muss vom **selben Webroot**
|
||
> beantwortet werden.
|
||
|
||
**1. DNS: Domain auf den Server zeigen lassen**
|
||
|
||
Beim Domain-Anbieter in der DNS- bzw. Zonenverwaltung anlegen:
|
||
|
||
| Typ | Name | Wert | Wofür |
|
||
|---|---|---|---|
|
||
| `A` | `qr` | `203.0.113.10` | IPv4 des Servers |
|
||
| `AAAA` | `qr` | `2001:db8::10` | IPv6, falls vorhanden |
|
||
| `CNAME` | `qr` | `short.example.de.` | Alternative zu A/AAAA |
|
||
|
||
Entweder A (+ AAAA) *oder* CNAME – nicht beides. Die richtige IP steht im Hosting-Panel
|
||
oder im vorhandenen Eintrag der Hauptdomain. Je nach TTL dauert die Verteilung wenige
|
||
Minuten bis einige Stunden.
|
||
|
||
Die Menüs heißen je nach Anbieter anders, gemeint ist immer dasselbe:
|
||
IONOS „Domains & SSL → Domain → DNS“ · Strato „Domainverwaltung → DNS-Einstellungen“ ·
|
||
Netcup „Domains → DNS“ · Hetzner „DNS Console → Zone → Records“ ·
|
||
Cloudflare „DNS → Records“ · All-Inkl „Domains → Einstellungen → DNS“.
|
||
Liegen Domain und Hosting beim selben Anbieter, wird der Eintrag beim Anlegen der
|
||
Subdomain oft automatisch gesetzt – dann entfällt dieser Schritt.
|
||
|
||
**2. Hosting: Domain auf dasselbe Verzeichnis legen**
|
||
|
||
- **Plesk:** „Websites & Domains → Subdomain hinzufügen“. Den vorgeschlagenen
|
||
**Dokumentenstamm** auf das Verzeichnis der vorhandenen Installation ändern (dort, wo
|
||
`index.php` liegt – meist `…/httpdocs/public`). Kein neues Hosting-Paket anlegen.
|
||
- **cPanel:** „Domains → Create A New Domain“, Haken bei *„Share document root“* setzen
|
||
bzw. den **Document Root** manuell auf das vorhandene Verzeichnis zeigen lassen.
|
||
- **Anderes Panel (IONOS, Strato, All-Inkl …):** „Subdomain anlegen“ bzw. „Domain
|
||
zuordnen“ mit frei wählbarem **Zielverzeichnis**. Manche nennen das „Alias-“ oder
|
||
„Addon-Domain“. Nicht geeignet: „Domain-Weiterleitung“ oder „Frame-Weiterleitung“.
|
||
- **Eigener Apache:**
|
||
|
||
```apache
|
||
<VirtualHost *:443>
|
||
ServerName short.example.de
|
||
ServerAlias qr.example.de
|
||
DocumentRoot /var/www/urlshort/public
|
||
</VirtualHost>
|
||
```
|
||
|
||
```bash
|
||
sudo apachectl configtest && sudo systemctl reload apache2
|
||
```
|
||
|
||
- **Eigener Nginx:**
|
||
|
||
```nginx
|
||
server {
|
||
server_name short.example.de qr.example.de;
|
||
root /var/www/urlshort/public;
|
||
}
|
||
```
|
||
|
||
```bash
|
||
sudo nginx -t && sudo systemctl reload nginx
|
||
```
|
||
|
||
- **Docker:** nichts zu tun – der Container beantwortet jeden Host. Steht ein Reverse
|
||
Proxy davor (Traefik, Nginx Proxy Manager, Caddy …), muss die Domain dort als weiterer
|
||
Host auf denselben Dienst zeigen.
|
||
|
||
**3. HTTPS: Zertifikat für die neue Domain**
|
||
|
||
Im Hosting-Panel bei der neuen (Sub-)Domain „Let’s Encrypt“ bzw. „SSL/TLS“ aktivieren –
|
||
cPanel erledigt das über AutoSSL meist selbst. Auf eigenem Server:
|
||
|
||
```bash
|
||
sudo certbot --expand -d short.example.de -d qr.example.de
|
||
```
|
||
|
||
Solange kein Zertifikat vorhanden ist, die Domain mit `http://` eintragen – sonst zeigen
|
||
Kurzlinks und QR-Codes auf eine Adresse, die der Browser nicht öffnen kann.
|
||
|
||
**4. Domain in der Anwendung eintragen**
|
||
|
||
Unter **Domains → Neue Domain**, exakt so, wie sie im Browser aufgerufen wird (gleiches
|
||
Schema, gleicher Port). Erst danach ordnet die Anwendung Aufrufe dieser Domain zu.
|
||
|
||
**5. Prüfen**
|
||
|
||
In der Domain-Liste gibt es je Eintrag einen Knopf **Prüfen**. Die Anwendung
|
||
löst den Hostnamen auf und ruft die Domain anschließend **von außen** auf – das Ergebnis
|
||
zeigt Schritt für Schritt, wie weit die Einrichtung ist:
|
||
|
||
| Prüfschritt | Was er belegt |
|
||
|---|---|
|
||
| **DNS** | Der Hostname löst auf – und auf welche IP-Adresse(n) |
|
||
| **Zuordnung** | Der Aufruf landet bei **dieser** Installation (nicht nur „irgendwo“) |
|
||
| **HTTPS** | Das Zertifikat gilt für diese Domain |
|
||
|
||
Für die Zuordnung wird ein Endpunkt unter `/.well-known/urlshort-check` aufgerufen. Er ist
|
||
nur mit einem kurzlebigen, signierten Token ansprechbar (Gültigkeit 3 Minuten) und
|
||
antwortet sonst mit `404`. Kommt der Aufruf mit gültigem Token an, ist damit bewiesen, dass
|
||
die Domain hier ankommt – nur diese Installation kann ein solches Token erzeugen.
|
||
|
||
> Eine reine DNS-Prüfung würde hier nicht reichen: Ein korrekter A-Record sagt nichts
|
||
> darüber aus, ob der Webserver die Domain auch dem richtigen Verzeichnis zuordnet – und
|
||
> genau das ist der häufigste Fehler.
|
||
|
||
Typische Ergebnisse und ihre Ursache:
|
||
|
||
| Meldung | Ursache |
|
||
|---|---|
|
||
| „lässt sich nicht auflösen“ | DNS-Eintrag fehlt oder ist noch nicht verteilt → Schritt 1 |
|
||
| „antwortet mit einer Weiterleitung“ | Domain-Weiterleitung beim Anbieter statt echter Zuordnung → Schritt 2 |
|
||
| „Antwort kommt nicht von dieser Installation“ | Die Domain zeigt auf ein anderes Verzeichnis oder eine zweite Installation → Schritt 2 |
|
||
| „Zertifikat gilt nicht für diese Domain“ | → Schritt 3 |
|
||
| „konnte nicht abgerufen werden“ | Webserver antwortet nicht auf diese Domain, Firewall oder Tippfehler |
|
||
|
||
**Wenn die Prüfung fehlschlägt, obwohl alles funktioniert:** Manche Server können ihren
|
||
eigenen öffentlichen Hostnamen nicht aufrufen (fehlendes NAT-Hairpinning). Lässt sich die
|
||
Domain von außen – etwa vom Handy über Mobilfunk – öffnen, ist alles in Ordnung. Die
|
||
Anwendung weist im Ergebnis selbst darauf hin.
|
||
|
||
Fehlen auf dem Server sowohl `ext-curl` als auch `allow_url_fopen`, kann die Zuordnung
|
||
nicht automatisch geprüft werden; die Prüfung sagt das dann ausdrücklich.
|
||
|
||
**Zuordnung im Detail:** Die Anwendung vergleicht den `Host`-Header der Anfrage mit den
|
||
eingetragenen Domains (Groß-/Kleinschreibung egal, Standardports `:80`/`:443` werden
|
||
ignoriert). Passt kein Eintrag – etwa beim Aufruf über die nackte IP-Adresse oder hinter
|
||
einem Proxy – greift die **Standarddomain**. Bestehende Links bleiben dadurch auch dann
|
||
erreichbar, wenn sich am Hostnamen etwas ändert.
|
||
|
||
Die Standarddomain kann nicht gelöscht werden; eine Domain mit Kurzlinks ebenfalls nicht,
|
||
solange noch Links daran hängen. Ein Link lässt sich jederzeit über **Bearbeiten** auf eine
|
||
andere Domain umziehen – bereits verteilte Links und gedruckte QR-Codes verlieren dabei
|
||
allerdings ihre Gültigkeit.
|
||
|
||
### Statistik lesen
|
||
|
||
**Kurzlinks → Statistik**: Gesamtklicks, Verlauf als Liniendiagramm (Zeitraum oben
|
||
rechts umschaltbar), Top-Referrer sowie die Aufteilung nach Gerät, Browser und
|
||
Plattform. Darunter die letzten 25 Aufrufe.
|
||
|
||
Steht `REDIRECT_STATUS` auf `301`, cachen Browser die Weiterleitung – wiederholte
|
||
Aufrufe derselben Person tauchen dann nicht in der Statistik auf. Für belastbare
|
||
Zahlen `302` verwenden.
|
||
|
||
---
|
||
|
||
## Projektstruktur
|
||
|
||
```
|
||
urlshort-qr-gen/
|
||
├── public/ ← Webroot
|
||
│ ├── index.php ← einziger Einstiegspunkt
|
||
│ ├── .htaccess ← Rewrites + Security-Header
|
||
│ └── assets/ ← CSS, JS, Favicon (kein Build-Schritt)
|
||
├── src/
|
||
│ ├── App.php ← Verdrahtung, Routing, Security-Header
|
||
│ ├── Config.php ← .env + Umgebungsvariablen
|
||
│ ├── Database.php ← PDO (SQLite/MySQL)
|
||
│ ├── Migrator.php ← führt migrations/ automatisch aus
|
||
│ ├── View.php ← minimale Template-Engine
|
||
│ ├── Http/ ← Request, Response, Router, BasePath
|
||
│ ├── Controller/ ← Setup, Auth, Dashboard, Link, Domain, Stats, Qr, Redirect
|
||
│ ├── Repository/ ← Datenzugriff (vorbereitete Statements)
|
||
│ ├── Security/ ← Session, Auth, CSRF, Rate-Limiting
|
||
│ ├── Service/ ← DomainChecker (DNS- und Zuordnungsprüfung)
|
||
│ ├── Qr/ ← QrOptions, QrRenderer, LogoStore
|
||
│ └── Support/ ← Slug, UrlValidator, Punycode, ShortDomain, UserAgentParser, Color
|
||
├── templates/ ← serverseitig gerenderte Seiten
|
||
├── migrations/ ← Schema je Treiber
|
||
├── data/ ← SQLite-Datei + hochgeladene Logos (beschreibbar!)
|
||
├── tests/ ← PHPUnit
|
||
├── docker/ ← php.ini, entrypoint.sh
|
||
├── Dockerfile
|
||
├── docker-compose.yml
|
||
├── .htaccess ← nur für Shared Hosting ohne eigenen Webroot
|
||
└── .env.example
|
||
```
|
||
|
||
---
|
||
|
||
## Sicherheit und Datenschutz
|
||
|
||
- **Nur Weiterleitungen sind öffentlich.** Jede andere Route verlangt eine gültige
|
||
Session; hochgeladene Logos werden ebenfalls nur nach Login ausgeliefert.
|
||
- **Ersteinrichtung:** Der Setup-Assistent verlangt zusätzlich ein Einmal-Token aus
|
||
`data/setup-token.txt` (steht auch im Serverprotokoll). Damit kann eine frisch
|
||
hochgeladene Installation nicht von jemandem übernommen werden, der sie zufällig
|
||
zuerst aufruft. Nach der Einrichtung wird die Datei gelöscht.
|
||
- **Datenbank:** ausschließlich vorbereitete Statements; Spalten- und
|
||
Sortiernamen kommen aus festen Allowlists.
|
||
- **XSS:** jede Ausgabe läuft durch `htmlspecialchars` mit `ENT_QUOTES`.
|
||
- **CSRF:** Synchronizer-Token in jedem Formular; ungültige Token ergeben 403.
|
||
- **Sessions:** `HttpOnly`, `SameSite=Lax`, `use_strict_mode`, ID-Wechsel beim Login,
|
||
Inaktivitätsgrenze **und** absolute Obergrenze. Über HTTPS wird das Cookie
|
||
automatisch auf `secure` gesetzt. Ein Passwortwechsel entwertet bestehende Sessions.
|
||
- **Login:** bcrypt, Fehlversuche werden pro IP-*Hash* gezählt und gesperrt. Dieselbe
|
||
Sperre gilt für das Einrichtungs-Token.
|
||
- **Proxy-Header:** `X-Forwarded-For` und `X-Forwarded-Proto` werden **nur** hinter
|
||
einem in `TRUSTED_PROXIES` eingetragenen Proxy ausgewertet. Ohne diese Einstellung
|
||
zählt allein `REMOTE_ADDR` – sonst ließe sich die Login-Sperre durch einen
|
||
erfundenen Header umgehen.
|
||
- **Host-Header:** wird gegen ein Muster geprüft, bevor er in erzeugte URLs einfließt
|
||
(und nur, wenn `APP_BASE_URL` nichts vorgibt).
|
||
- **Security-Header:** CSP ohne `unsafe-inline` für Skripte, `X-Content-Type-Options`,
|
||
`X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`,
|
||
`Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`; über HTTPS zusätzlich
|
||
HSTS und `upgrade-insecure-requests`.
|
||
- **Externe Skripte:** Chart.js ist auf eine Version festgelegt und per **Subresource
|
||
Integrity** an seinen Hash gebunden – ein manipuliertes CDN könnte keinen fremden
|
||
Code einschleusen.
|
||
- **Uploads:** nur PNG/JPG/SVG, Prüfung gegen GD (beschädigte Dateien werden
|
||
abgelehnt), Größen- und Abmessungsgrenzen, eigener Dateiname, Ablage außerhalb des
|
||
Webroots. SVGs werden beim Speichern von Skripten, Event-Handlern, Entities (XXE),
|
||
Stylesheet-Anweisungen und allen externen Referenzen befreit; beim Ausliefern
|
||
greift zusätzlich eine abschottende CSP (`sandbox`), sodass ein durchgerutschtes
|
||
SVG im Browser trotzdem keinen Code ausführen könnte.
|
||
- **Domain-Prüfung:** ruft nur einen festen Pfad ab, folgt keinen Weiterleitungen,
|
||
begrenzt die Antwortgröße und verweigert link-lokale Adressen (`169.254.0.0/16`,
|
||
`fe80::/10`) – dort liegen bei Cloud-Anbietern die Metadaten-Dienste.
|
||
- **DSGVO:** IP-Adressen werden standardmäßig **nicht** gespeichert. Vom User-Agent
|
||
bleiben nur Gerätetyp, Browser und Plattform als Kategorien übrig; der Rohstring
|
||
wird zwar abgelegt, aber nur gekürzt und über `VISIT_RETENTION_DAYS` automatisch
|
||
wieder gelöscht. Ein Cookie-Banner ist für die Weiterleitungen nicht nötig, da
|
||
dort keine Cookies gesetzt werden.
|
||
|
||
Das einzige Fremd-CDN ist Chart.js (`cdn.jsdelivr.net`) für das Liniendiagramm im
|
||
Admin-Bereich. Ist es nicht erreichbar, zeichnet die Anwendung ein einfaches
|
||
Balkendiagramm selbst. Wer ganz ohne CDN auskommen will, legt
|
||
`chart.umd.min.js` unter `public/assets/` ab und passt die URL in
|
||
`public/assets/app.js` an.
|
||
|
||
---
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
composer install
|
||
vendor/bin/phpunit
|
||
```
|
||
|
||
Ohne lokales PHP über Docker:
|
||
|
||
```bash
|
||
docker run --rm -v "$PWD:/app" -w /app urlshort-qr-gen:latest vendor/bin/phpunit
|
||
```
|
||
|
||
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
|