diff --git a/README.md b/README.md index d5b88ae..acbaf8a 100644 --- a/README.md +++ b/README.md @@ -82,15 +82,41 @@ Benutzername und Passwort. Es wird **keine Shell** benötigt. Der `vendor/`-Ordner ist im Release-Paket enthalten. -### Paket vorbereiten - -Wenn du das Projekt aus dem Repository nutzt und Composer lokal (oder per Docker) hast: +### Upload-Paket erzeugen ```bash -composer install --no-dev --optimize-autoloader +sh build-release.sh ``` -Danach enthält der Ordner alles, was hochgeladen werden muss. +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) @@ -131,6 +157,46 @@ Zusätzlich liegt in `data/` eine eigene `.htaccess` als zweite Absicherung. > 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. diff --git a/build-release.sh b/build-release.sh new file mode 100644 index 0000000..5e4c891 --- /dev/null +++ b/build-release.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# ------------------------------------------------------------- +# Erzeugt das Upload-Paket fuer klassisches Webhosting. +# +# Ergebnis: build/urlshort-qr-gen-release/ und build/urlshort-qr-gen.zip +# Enthalten ist alles, was auf dem Server gebraucht wird - inklusive +# vendor/ ohne Entwicklungspakete. Nicht enthalten sind Tests, Docker, +# Git-Dateien und die eigene .env. +# +# Aufruf mit lokalem PHP und Composer (im Projektverzeichnis): +# sh build-release.sh +# +# Ohne lokales PHP ueber das Anwendungs-Image - dieses bringt PHP samt +# gd, intl und Composer mit (der schlanke composer:2 hat kein gd und +# bricht deshalb an der Plattformpruefung ab): +# docker compose build +# docker run --rm -v "$PWD:/app" -w /app urlshort-qr-gen:latest sh build-release.sh +# ------------------------------------------------------------- +set -eu + +ROOT="$(pwd)" +BUILD="$ROOT/build" +TARGET="$BUILD/urlshort-qr-gen-release" + +echo "==> Aufraeumen" +rm -rf "$BUILD" +mkdir -p "$TARGET" + +echo "==> Dateien kopieren" +for item in public src templates migrations composer.json composer.lock .env.example README.md .htaccess; do + cp -R "$ROOT/$item" "$TARGET/" +done + +mkdir -p "$TARGET/data/uploads" +cp "$ROOT/data/.htaccess" "$TARGET/data/.htaccess" +: > "$TARGET/data/.gitkeep" + +echo "==> Abhaengigkeiten ohne Entwicklungspakete installieren" +cd "$TARGET" +composer install --no-dev --optimize-autoloader --no-interaction --no-progress --quiet + +# composer.lock wird auf dem Server nicht gebraucht, composer.json auch +# nicht - beide sind ueber die .htaccess ohnehin gesperrt. Sie bleiben +# liegen, damit sich das Paket spaeter aktualisieren laesst. + +# endroid bringt eine 16 MB grosse Schriftart mit. Gebraucht wird sie +# ausschliesslich fuer Beschriftungen UNTER dem QR-Code (Label) - diese +# Anwendung erzeugt keine, der QrRenderer uebergibt dort immer null. +# Ohne die Datei schrumpft der Upload von rund 18 MB auf gut 2 MB. +# Sollte spaeter doch eine Beschriftung dazukommen, faellt das hier auf +# und die Schriftart bleibt im Paket. +echo "==> Ungenutzte Schriftart pruefen" +if grep -rqE 'Endroid\\\\QrCode\\\\Label|new Label\(' "$TARGET/src" 2>/dev/null; then + echo " Label wird verwendet - Schriftart bleibt erhalten." +else + rm -f "$TARGET/vendor/endroid/qr-code/assets/noto_sans.otf" + echo " noto_sans.otf entfernt (wird nicht verwendet)." +fi + +echo "==> Archiv erzeugen" +cd "$BUILD" +if command -v zip >/dev/null 2>&1; then + zip -rq urlshort-qr-gen.zip urlshort-qr-gen-release + echo " build/urlshort-qr-gen.zip" +else + tar -czf urlshort-qr-gen.tar.gz urlshort-qr-gen-release + echo " build/urlshort-qr-gen.tar.gz (kein zip vorhanden)" + echo " Unter Windows laesst sich stattdessen ein ZIP erzeugen mit:" + echo " Compress-Archive build/urlshort-qr-gen-release build/urlshort-qr-gen.zip" +fi + +echo "==> Fertig" +echo " Verzeichnis: build/urlshort-qr-gen-release" +echo " Dateien: $(find "$TARGET" -type f | wc -l)" +echo " Groesse: $(du -sh "$TARGET" | cut -f1)" diff --git a/composer.lock b/composer.lock index d4b9861..6615434 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3486c41b4f3b5ad75dcd8b39db645306", + "content-hash": "f1b5bd346bb675500290d6e5402ce9ab", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1847,7 +1847,8 @@ "php": ">=8.2", "ext-json": "*", "ext-pdo": "*", - "ext-gd": "*" + "ext-gd": "*", + "ext-mbstring": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/src/App.php b/src/App.php index 785b6f8..290197d 100644 --- a/src/App.php +++ b/src/App.php @@ -28,6 +28,7 @@ use App\Security\RateLimiter; use App\Security\Session; use App\Security\SetupToken; use App\Service\DomainChecker; +use App\Service\SystemCheck; use Throwable; /** @@ -137,9 +138,18 @@ final class App $this->auth, new SetupToken($this->config->path($this->config->getString('DATA_DIR', 'data'))), $this->rateLimiter, + new SystemCheck($this->config), ); $login = new AuthController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->rateLimiter); - $dashboard = new DashboardController($this->view, $this->auth, $this->links, $this->visits, $this->config, $this->domains); + $dashboard = new DashboardController( + $this->view, + $this->auth, + $this->links, + $this->visits, + $this->config, + $this->domains, + new SystemCheck($this->config), + ); $linkController = new LinkController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->domains); $stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits); $qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr); diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 9256341..494e1f9 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -12,6 +12,7 @@ use App\Repository\LinkRepository; use App\Repository\VisitRepository; use App\Security\Auth; use App\Security\Session; +use App\Service\SystemCheck; use App\View; final class DashboardController extends Controller @@ -23,6 +24,7 @@ final class DashboardController extends Controller private readonly VisitRepository $visits, Config $config, private readonly DomainRepository $domains, + private readonly SystemCheck $systemCheck, ) { parent::__construct($config, $view); } @@ -46,6 +48,9 @@ final class DashboardController extends Controller 'topLinks' => $this->links->all('', 'clicks_desc', 5), 'series' => $this->visits->clicksPerDayGlobal(30), 'domainCount' => $this->domains->count(), + // Nur melden, wenn etwas nicht stimmt - im Normalfall + // soll die Uebersicht nicht mit Technik zugestellt werden. + 'systemProblems' => $this->systemCheck->problems(), 'pageTitle' => 'Übersicht', ]); } diff --git a/src/Controller/SetupController.php b/src/Controller/SetupController.php index 844f8e9..578fc1b 100644 --- a/src/Controller/SetupController.php +++ b/src/Controller/SetupController.php @@ -12,6 +12,7 @@ use App\Security\Csrf; use App\Security\RateLimiter; use App\Security\Session; use App\Security\SetupToken; +use App\Service\SystemCheck; use App\View; /** @@ -37,6 +38,7 @@ final class SetupController extends Controller private readonly Auth $auth, private readonly SetupToken $setupToken, private readonly RateLimiter $rateLimiter, + private readonly SystemCheck $systemCheck, ) { parent::__construct($config, $view); } @@ -126,6 +128,8 @@ final class SetupController extends Controller 'errors' => $errors, 'minLength' => self::MIN_PASSWORD_LENGTH, 'tokenPath' => $this->setupToken->path(), + 'systemChecks' => $this->systemCheck->all(), + 'systemStatus' => $this->systemCheck->worstStatus(), ], $status, 'layout_plain'); } } diff --git a/src/Service/SystemCheck.php b/src/Service/SystemCheck.php new file mode 100644 index 0000000..03147c6 --- /dev/null +++ b/src/Service/SystemCheck.php @@ -0,0 +1,221 @@ + + */ + public function all(): array + { + return [ + $this->php(), + $this->database(), + $this->extension('gd', 'Bilderzeugung (gd)', true, 'Ohne gd lassen sich keine PNG-QR-Codes erzeugen; SVG funktioniert weiterhin.'), + $this->extension('mbstring', 'Zeichenketten (mbstring)', true, 'Wird für Umlaute in Slugs, URLs und Domains gebraucht.'), + $this->extension('fileinfo', 'Dateityperkennung (fileinfo)', true, 'Wird beim Prüfen hochgeladener Logos gebraucht.'), + $this->dataDirectory(), + $this->uploadLimit(), + $this->extension('intl', 'Umlaut-Domains (intl)', false, 'Ohne intl greift ein eingebauter Punycode-Rückfallweg – Umlaut-Domains funktionieren trotzdem.'), + $this->extension('imagick', 'SVG-Logos in PNG (imagick)', false, 'Ohne imagick erscheinen SVG-Logos nur im SVG-Download. PNG-Logos sind davon nicht betroffen.'), + $this->outgoingRequests(), + ]; + } + + /** + * Fasst zusammen, ob etwas zu tun ist. + */ + public function worstStatus(): string + { + $status = self::OK; + + foreach ($this->all() as $check) { + if ($check['status'] === self::ERROR) { + return self::ERROR; + } + if ($check['status'] === self::WARNING) { + $status = self::WARNING; + } + } + + return $status; + } + + /** + * @return list + */ + public function problems(): array + { + return array_values(array_filter( + $this->all(), + static fn (array $check): bool => $check['status'] !== self::OK, + )); + } + + /** + * @return array{label:string,status:string,text:string,required:bool} + */ + private function php(): array + { + $ok = version_compare(PHP_VERSION, self::MIN_PHP, '>='); + + return [ + 'label' => 'PHP-Version', + 'status' => $ok ? self::OK : self::ERROR, + 'text' => $ok + ? PHP_VERSION + : sprintf('%s – benötigt wird mindestens %s. Im Hosting-Panel umstellen.', PHP_VERSION, self::MIN_PHP), + 'required' => true, + ]; + } + + /** + * @return array{label:string,status:string,text:string,required:bool} + */ + private function database(): array + { + $driver = strtolower($this->config->getString('DB_DRIVER', 'sqlite')); + $needed = $driver === 'mysql' || $driver === 'mariadb' ? 'pdo_mysql' : 'pdo_sqlite'; + $ok = extension_loaded('pdo') && extension_loaded($needed); + + return [ + 'label' => 'Datenbank', + 'status' => $ok ? self::OK : self::ERROR, + 'text' => $ok + ? sprintf('%s über %s', $driver === 'sqlite' ? 'SQLite' : 'MySQL/MariaDB', $needed) + : sprintf('Die PHP-Erweiterung %s fehlt.', $needed), + 'required' => true, + ]; + } + + /** + * @return array{label:string,status:string,text:string,required:bool} + */ + private function extension(string $name, string $label, bool $required, string $hint): array + { + $loaded = extension_loaded($name); + + return [ + 'label' => $label, + 'status' => $loaded ? self::OK : ($required ? self::ERROR : self::WARNING), + 'text' => $loaded ? 'vorhanden' : ('nicht vorhanden – ' . $hint), + 'required' => $required, + ]; + } + + /** + * @return array{label:string,status:string,text:string,required:bool} + */ + private function dataDirectory(): array + { + $path = $this->config->path($this->config->getString('DATA_DIR', 'data')); + $writable = is_dir($path) && is_writable($path); + + return [ + 'label' => 'Datenverzeichnis', + 'status' => $writable ? self::OK : self::ERROR, + 'text' => $writable + ? $path . ' ist beschreibbar' + : sprintf('%s ist nicht beschreibbar. Rechte auf 755 bzw. 775 setzen.', $path), + 'required' => true, + ]; + } + + /** + * @return array{label:string,status:string,text:string,required:bool} + */ + private function uploadLimit(): array + { + $wanted = $this->config->getInt('UPLOAD_MAX_BYTES', 2097152); + $limit = min( + self::toBytes((string) ini_get('upload_max_filesize')), + self::toBytes((string) ini_get('post_max_size')), + ); + + $ok = $limit <= 0 || $limit >= $wanted; + + return [ + 'label' => 'Upload-Größe', + 'status' => $ok ? self::OK : self::WARNING, + 'text' => $ok + ? sprintf('bis %s je Logo', self::format($limit > 0 ? $limit : $wanted)) + : sprintf( + 'Der Server erlaubt nur %s, eingestellt sind %s. Logos werden entsprechend früher abgelehnt.', + self::format($limit), + self::format($wanted), + ), + 'required' => false, + ]; + } + + /** + * @return array{label:string,status:string,text:string,required:bool} + */ + private function outgoingRequests(): array + { + $curl = function_exists('curl_init'); + $fopen = (bool) ini_get('allow_url_fopen'); + $ok = $curl || $fopen; + + return [ + 'label' => 'Domain-Prüfung', + 'status' => $ok ? self::OK : self::WARNING, + 'text' => $ok + ? ($curl ? 'über cURL möglich' : 'über allow_url_fopen möglich') + : 'Weder cURL noch allow_url_fopen verfügbar – die Domain-Prüfung kann die Zuordnung nicht testen. ' + . 'Alles Übrige funktioniert davon unberührt.', + 'required' => false, + ]; + } + + private static function toBytes(string $value): int + { + $value = trim($value); + if ($value === '') { + return 0; + } + + $unit = strtolower(substr($value, -1)); + $number = (int) $value; + + return match ($unit) { + 'g' => $number * 1024 * 1024 * 1024, + 'm' => $number * 1024 * 1024, + 'k' => $number * 1024, + default => $number, + }; + } + + private static function format(int $bytes): string + { + if ($bytes >= 1024 * 1024) { + return round($bytes / 1024 / 1024, 1) . ' MB'; + } + + return max(1, intdiv($bytes, 1024)) . ' KB'; + } +} diff --git a/templates/dashboard/index.php b/templates/dashboard/index.php index 72dad1c..8f8e893 100644 --- a/templates/dashboard/index.php +++ b/templates/dashboard/index.php @@ -10,10 +10,42 @@ */ use App\Repository\LinkRepository; +use App\Service\SystemCheck; use App\Support\ShortDomain; $fallbackBase = rtrim($config->baseUrl(), '/'); ?> + +
+
+ +
+

Hinweise zum Server

+

+ +

+
+
+ +
+

Übersicht

diff --git a/templates/setup/index.php b/templates/setup/index.php index e190f44..ff002e4 100644 --- a/templates/setup/index.php +++ b/templates/setup/index.php @@ -6,8 +6,22 @@ * @var array $errors * @var int $minLength * @var string $tokenPath + * @var list $systemChecks + * @var string $systemStatus */ +use App\Service\SystemCheck; + +$symbol = static fn (string $status): string => match ($status) { + SystemCheck::OK => '✓', + SystemCheck::WARNING => '!', + default => '×', +}; +$cssClass = static fn (string $status): string => match ($status) { + SystemCheck::OK => 'ok', + SystemCheck::WARNING => 'warn', + default => 'error', +}; ?>
@@ -83,3 +97,29 @@ (ADMIN_USER und ADMIN_PASSWORD_HASH). Details stehen in der README.

+ +
+
> + + Systemvoraussetzungen – + + + +
    + +
  • + + + +
  • + +
+ +

+ Dass diese Seite überhaupt erscheint, belegt bereits, dass mod_rewrite arbeitet + und die .htaccess ausgewertet wird. +

+
+