diff --git a/README.md b/README.md index acbaf8a..ef02d13 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Benutzername und Passwort. | | | |---|---| -| PHP | 8.2 oder neuer | +| 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 | @@ -682,6 +682,12 @@ 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 diff --git a/composer.json b/composer.json index f08c72c..d15c399 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "ext-pdo": "*", "ext-gd": "*", "ext-mbstring": "*", - "endroid/qr-code": "^5.0" + "endroid/qr-code": "^6.0" }, "suggest": { "ext-intl": "Umlaut-Domains (IDN) nach UTS-46 umwandeln; ohne intl greift ein eigener Punycode-Rueckfallweg", diff --git a/composer.lock b/composer.lock index 6615434..d754072 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": "f1b5bd346bb675500290d6e5402ce9ab", + "content-hash": "a205de8679621b2557540230105a5cd1", "packages": [ { "name": "bacon/bacon-qr-code", @@ -113,21 +113,21 @@ }, { "name": "endroid/qr-code", - "version": "5.1.0", + "version": "6.0.9", "source": { "type": "git", "url": "https://github.com/endroid/qr-code.git", - "reference": "393fec6c4cbdc1bd65570ac9d245704428010122" + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/endroid/qr-code/zipball/393fec6c4cbdc1bd65570ac9d245704428010122", - "reference": "393fec6c4cbdc1bd65570ac9d245704428010122", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "endroid/quality": "dev-main", @@ -144,7 +144,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "5.x-dev" + "dev-main": "6.x-dev" } }, "autoload": { @@ -173,7 +173,7 @@ ], "support": { "issues": "https://github.com/endroid/qr-code/issues", - "source": "https://github.com/endroid/qr-code/tree/5.1.0" + "source": "https://github.com/endroid/qr-code/tree/6.0.9" }, "funding": [ { @@ -181,7 +181,7 @@ "type": "github" } ], - "time": "2024-09-08T08:52:55+00:00" + "time": "2025-07-13T19:59:45+00:00" } ], "packages-dev": [ diff --git a/public/index.php b/public/index.php index 87534d2..0facee0 100644 --- a/public/index.php +++ b/public/index.php @@ -17,6 +17,15 @@ declare(strict_types=1); use App\App; use App\Config; +// Ausgabepuffer, bevor irgendetwas ausgegeben werden kann: Manche +// Hoster erzwingen display_errors und lassen sich davon per ini_set() +// nicht abbringen. Eine einzige Meldung wuerde sonst genuegen, damit +// keine Header mehr gesendet werden koennen - keine Session, keine +// Weiterleitung - und jeder PNG- oder SVG-Download waere beschaedigt. +// Gepuffert laesst sich solche Streuausgabe vor dem Senden verwerfen +// und stattdessen protokollieren. +ob_start(); + $rootDir = dirname(__DIR__); $autoload = $rootDir . '/vendor/autoload.php'; diff --git a/src/App.php b/src/App.php index 290197d..716954b 100644 --- a/src/App.php +++ b/src/App.php @@ -116,6 +116,7 @@ final class App } } + $response = $this->discardStrayOutput($response); $response->send(); $this->finishRequest(); @@ -291,6 +292,46 @@ final class App return Response::html($html, 500); } + /** + * Leert die Ausgabepuffer, bevor die eigentliche Antwort rausgeht. + * + * Alles, was sich dort angesammelt hat, stammt nicht von dieser + * Anwendung (die Templates rendern in eigene Puffer und liefern + * Zeichenketten zurueck), sondern von PHP-Meldungen des Servers. + * Solche Streuausgabe wuerde Header verhindern und Binaerdownloads + * beschaedigen - sie wandert deshalb ins Fehlerprotokoll. + */ + private function discardStrayOutput(Response $response): Response + { + $stray = ''; + while (ob_get_level() > 0) { + $stray .= (string) ob_get_clean(); + } + + if (trim($stray) === '') { + return $response; + } + + error_log('[urlshort] Unerwartete Ausgabe vor der Antwort: ' . mb_substr(trim($stray), 0, 1000)); + + // Im Entwicklungsmodus nicht stillschweigend verschlucken - + // aber nur bei HTML, sonst waere das Bild wieder kaputt. + $isHtml = str_starts_with($response->headers()['Content-Type'] ?? '', 'text/html'); + if ($this->config->isDevelopment() && $isHtml) { + // Bewusst ueber den Konstruktor, damit Status und die eben + // gesetzten Security-Header erhalten bleiben. + return new Response( + '
'
+                . htmlspecialchars(trim($stray), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
+                . '
' . $response->body(), + $response->status(), + $response->headers(), + ); + } + + return $response; + } + /** * Antwort ausliefern und die Verbindung schliessen, damit Nacharbeiten * (Klick-Protokollierung) den Nutzer nicht warten lassen. diff --git a/src/Qr/LogoStore.php b/src/Qr/LogoStore.php index 5f0f3e7..e12cf0e 100644 --- a/src/Qr/LogoStore.php +++ b/src/Qr/LogoStore.php @@ -135,7 +135,10 @@ final class LogoStore ); } - imagedestroy($image); + // Kein imagedestroy(): seit PHP 8.0 wirkungslos (GdImage ist ein + // Objekt), seit 8.5 als veraltet gemeldet. Der Speicher wird + // freigegeben, sobald die Variable ausser Reichweite geraet. + unset($image); } private function detectMimeType(string $path, string $originalName): string @@ -158,7 +161,9 @@ final class LogoStore $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo !== false) { $mime = finfo_file($finfo, $path); - finfo_close($finfo); + // Kein finfo_close(): finfo-Objekte raeumt PHP selbst auf, + // seit 8.5 ist der Aufruf als veraltet gemeldet. + unset($finfo); if (is_string($mime)) { return $mime; } diff --git a/src/Security/Session.php b/src/Security/Session.php index f639565..fc9d0a3 100644 --- a/src/Security/Session.php +++ b/src/Security/Session.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Security; use App\Config; +use RuntimeException; /** * Sichere Session-Konfiguration. @@ -47,8 +48,24 @@ final class Session ini_set('session.use_only_cookies', '1'); ini_set('session.cookie_httponly', '1'); ini_set('session.gc_maxlifetime', (string) $lifetime); - ini_set('session.sid_length', '48'); - ini_set('session.sid_bits_per_character', '5'); + + // session.sid_length und session.sid_bits_per_character werden + // hier bewusst NICHT gesetzt: PHP 8.4 hat beide als veraltet + // markiert. Die Voreinstellungen (32 Zeichen aus 4 Bit je + // Zeichen = 128 Bit Entropie) sind ohnehin ausreichend. + + // Sind bereits Ausgaben rausgegangen - etwa eine Meldung des + // Hosters -, kann keine Session mehr beginnen. Statt einer + // Kaskade aus Warnungen dann lieber eine klare Fehlermeldung. + if (headers_sent($file, $line)) { + throw new RuntimeException(sprintf( + 'Die Sitzung kann nicht gestartet werden, weil bereits Ausgaben gesendet wurden (%s Zeile %d). ' + . 'Meist steckt eine PHP-Meldung des Servers dahinter; mit APP_ENV=production werden solche ' + . 'Meldungen unterdrueckt.', + $file !== '' ? $file : 'unbekannt', + $line, + )); + } session_start(); $this->started = true; diff --git a/src/Service/DomainChecker.php b/src/Service/DomainChecker.php index 60fa644..accd2a3 100644 --- a/src/Service/DomainChecker.php +++ b/src/Service/DomainChecker.php @@ -355,7 +355,10 @@ final class DomainChecker $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $location = (string) (curl_getinfo($handle, CURLINFO_REDIRECT_URL) ?: ''); $message = curl_error($handle); - curl_close($handle); + // Kein curl_close(): seit PHP 8.0 wirkungslos (CurlHandle ist ein + // Objekt und wird selbst aufgeraeumt), seit 8.5 als veraltet + // gemeldet. Das Handle faellt hier ohnehin aus dem Gueltigkeitsbereich. + unset($handle); // Abbruch durch die Groessenbegrenzung ist kein echter Fehler. if ($errorNumber === CURLE_WRITE_ERROR && $status > 0) { diff --git a/tests/LogoStoreTest.php b/tests/LogoStoreTest.php index a7f139e..34de66e 100644 --- a/tests/LogoStoreTest.php +++ b/tests/LogoStoreTest.php @@ -64,7 +64,6 @@ final class LogoStoreTest extends TestCase ob_start(); imagepng($image); $data = (string) ob_get_clean(); - imagedestroy($image); return $data; } diff --git a/tests/PunycodeTest.php b/tests/PunycodeTest.php index 8faed05..f4f7d79 100644 --- a/tests/PunycodeTest.php +++ b/tests/PunycodeTest.php @@ -87,8 +87,8 @@ final class PunycodeTest extends TestCase */ private static function encodeWithoutIntl(string $host): string { + // Kein setAccessible(): seit PHP 8.1 wirkungslos, seit 8.5 veraltet. $method = new \ReflectionMethod(Punycode::class, 'encodeLabel'); - $method->setAccessible(true); $labels = array_map( static fn (string $label): string => (string) $method->invoke(null, $label), diff --git a/tests/QrRendererTest.php b/tests/QrRendererTest.php index 972fc8f..61733d9 100644 --- a/tests/QrRendererTest.php +++ b/tests/QrRendererTest.php @@ -159,6 +159,5 @@ final class QrRendererTest extends TestCase self::assertNotFalse($image); imagefill($image, 0, 0, (int) imagecolorallocate($image, 255, 0, 0)); imagepng($image, $path); - imagedestroy($image); } }