Files
urlshort-qr-gen/src/App.php
T
florian.wilde 583d9f0ae2 feat(links): Mehrteilige Slugs und erhaltene Gross-/Kleinschreibung
Wer bereits gedruckte QR-Codes aus einem anderen System uebernimmt,
kann deren Adressen nicht mehr aendern. Sehen die aus wie
"/g/1AEJ0X90ZmF", waren sie hier bisher nicht abbildbar: der
Normalisierer machte daraus "g-1aej0x90zmf" - Schraegstrich zu
Bindestrich, alles klein. Der Kurzlink zeigte damit ins Leere.

Ein Slug darf jetzt mehrere durch "/" getrennte Abschnitte haben
(hoechstens acht, je 64 Zeichen) und behaelt die eingegebene
Schreibweise. Erlaubt sind Buchstaben, Ziffern, Bindestrich,
Unterstrich und Punkt; jeder Abschnitt muss mit einem Buchstaben oder
einer Ziffer beginnen und enden. Umlaute und Leerzeichen werden
weiterhin umgeschrieben. Wird versehentlich eine komplette URL
eingefuegt, bleibt davon nur der Pfad uebrig.

Damit die Verwaltung nicht verdeckt werden kann, greift die Liste
reservierter Namen jetzt auf den ERSTEN Abschnitt: "admin/geheim" wird
ebenso abgelehnt wie "admin". Die Route der Weiterleitung steht
weiterhin ganz am Ende, alle Verwaltungsrouten gewinnen davor.

Bei der Eindeutigkeit gilt bewusst das Gegenteil: sie wird OHNE
Ruecksicht auf Gross- und Kleinschreibung geprueft. "Messe" und "messe"
koennen auf derselben Domain nicht nebeneinander bestehen - an einer
gedruckten Adresse waere sonst nicht mehr erkennbar, welcher Link
gemeint ist. Ausserdem wuerde MySQL sie je nach Kollation ohnehin als
gleich ansehen, SQLite nicht; so verhalten sich beide Treiber wieder
gleich.

Beim Aufruf wird zuerst exakt gesucht und nur bei einem Fehlschlag ohne
Ruecksicht auf die Schreibweise - abgetippte Codes stimmen nicht immer.
Weil die Eindeutigkeit oben schon ohne Ruecksicht geprueft wurde, kann
dieser zweite Versuch nie mehrdeutig sein.

Geprueft in beiden Betriebsarten: im Container und in der
Shared-Hosting-Variante, bei der die Root-.htaccess nach public/
umleitet - dort sind mehrteilige Pfade der wahrscheinlichste
Stolperstein.
2026-08-12 18:32:41 +02:00

364 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App;
use App\Controller\AuthController;
use App\Controller\DashboardController;
use App\Controller\LinkController;
use App\Controller\QrController;
use App\Controller\RedirectController;
use App\Controller\SetupController;
use App\Controller\StatsController;
use App\Http\Request;
use App\Http\Response;
use App\Http\Router;
use App\Qr\LogoStore;
use App\Qr\QrRenderer;
use App\Controller\DomainController;
use App\Repository\DomainRepository;
use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository;
use App\Repository\SettingRepository;
use App\Repository\VisitRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\RateLimiter;
use App\Security\Session;
use App\Security\SetupToken;
use App\Service\DomainChecker;
use App\Service\SystemCheck;
use Throwable;
/**
* Verdrahtung aller Bausteine und Einstiegspunkt fuer jeden Request.
*/
final class App
{
private Database $db;
private Session $session;
private Csrf $csrf;
private Auth $auth;
private View $view;
private LinkRepository $links;
private VisitRepository $visits;
private QrDesignRepository $designs;
private DomainRepository $domains;
private SettingRepository $settings;
private LogoStore $logos;
private QrRenderer $qr;
private RateLimiter $rateLimiter;
public function __construct(private readonly Config $config)
{
date_default_timezone_set($config->getString('APP_TIMEZONE', 'Europe/Berlin'));
$this->db = Database::fromConfig($config);
(new Migrator($this->db, $config->rootDir() . '/migrations'))->migrate();
$settings = new SettingRepository($this->db);
$this->settings = $settings;
$this->session = new Session($config);
$this->csrf = new Csrf($this->session);
$this->auth = new Auth($config, $this->session, $settings);
$this->links = new LinkRepository($this->db);
$this->visits = new VisitRepository($this->db);
$this->designs = new QrDesignRepository($this->db);
$this->domains = new DomainRepository($this->db);
$this->logos = new LogoStore(
$config->path($config->getString('UPLOAD_DIR', 'data/uploads')),
$config->getInt('UPLOAD_MAX_BYTES', 2097152),
);
$this->qr = new QrRenderer($this->logos);
$this->rateLimiter = new RateLimiter(
$this->db,
$config->getInt('LOGIN_MAX_ATTEMPTS', 5),
$config->getInt('LOGIN_LOCKOUT_SECONDS', 900),
);
$this->view = new View($config->rootDir() . '/templates');
$this->view->share('config', $config);
$this->view->share('csrf', $this->csrf);
$this->view->share('auth', $this->auth);
$this->view->share('baseUrl', $config->baseUrl());
}
public function run(): void
{
$request = Request::fromGlobals($this->config->trustedProxies());
// Nur ein syntaktisch gueltiger Host darf in erzeugte URLs
// gelangen - und auch nur, wenn APP_BASE_URL nichts vorgibt.
$host = $request->host();
if ($host !== '') {
$this->config->setFallbackBaseUrl(($request->isSecure() ? 'https://' : 'http://') . $host);
}
$this->session->start($request->isSecure());
$this->view->share('flashes', $this->session->takeFlashes());
$this->view->share('currentPath', $request->path);
try {
$response = $this->buildRouter()->dispatch($request);
} catch (Throwable $e) {
$response = $this->errorResponse($e);
}
$existingHeaders = $response->headers();
foreach ($this->securityHeaders($request) as $name => $value) {
if (!array_key_exists($name, $existingHeaders)) {
$response = $response->withHeader($name, $value);
}
}
$response = $this->discardStrayOutput($response);
$response->send();
$this->finishRequest();
try {
$response->runAfterSend();
} catch (Throwable $e) {
error_log('[urlshort] Nachlauf fehlgeschlagen: ' . $e->getMessage());
}
}
private function buildRouter(): Router
{
$router = new Router();
$setup = new SetupController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$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,
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);
$domainController = new DomainController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->domains,
new DomainChecker($this->settings),
$this->settings,
);
$redirect = new RedirectController($this->config, $this->view, $this->links, $this->visits, $this->domains);
// --- Setup (nur solange kein Admin-Konto existiert) ------------
$router->get('/setup', [$setup, 'show']);
$router->post('/setup', [$setup, 'save']);
// --- Login ------------------------------------------------------
$router->get('/login', [$login, 'show']);
$router->post('/login', [$login, 'authenticate']);
$router->post('/logout', [$login, 'logout']);
// --- Geschuetzter Bereich ---------------------------------------
$router->get('/', [$dashboard, 'index']);
$router->get('/admin', [$dashboard, 'index']);
$router->get('/admin/links', [$linkController, 'index']);
$router->get('/admin/links/new', [$linkController, 'create']);
$router->post('/admin/links/new', [$linkController, 'store']);
$router->get('/admin/links/{id}/edit', [$linkController, 'edit']);
$router->post('/admin/links/{id}/edit', [$linkController, 'update']);
$router->post('/admin/links/{id}/toggle', [$linkController, 'toggle']);
$router->post('/admin/links/{id}/delete', [$linkController, 'destroy']);
$router->get('/admin/links/{id}/stats', [$stats, 'show']);
$router->get('/admin/links/{id}/stats.json', [$stats, 'data']);
$router->get('/admin/links/{id}/qr', [$qrController, 'show']);
$router->post('/admin/links/{id}/qr', [$qrController, 'save']);
$router->post('/admin/links/{id}/qr/logo', [$qrController, 'uploadLogo']);
$router->post('/admin/links/{id}/qr/logo/delete', [$qrController, 'deleteLogo']);
$router->get('/admin/links/{id}/qr/preview', [$qrController, 'preview']);
$router->get('/admin/links/{id}/qr/download', [$qrController, 'download']);
$router->get('/admin/uploads/{file}', [$qrController, 'serveUpload']);
$router->get('/admin/domains', [$domainController, 'index']);
$router->get('/admin/domains/new', [$domainController, 'create']);
$router->post('/admin/domains/new', [$domainController, 'store']);
$router->get('/admin/domains/{id}/edit', [$domainController, 'edit']);
$router->post('/admin/domains/{id}/edit', [$domainController, 'update']);
$router->post('/admin/domains/{id}/default', [$domainController, 'makeDefault']);
$router->post('/admin/domains/{id}/delete', [$domainController, 'destroy']);
$router->post('/admin/domains/{id}/check', [$domainController, 'check']);
// Endpunkt der Domain-Pruefung: ohne Login erreichbar, aber nur
// mit gueltigem, kurzlebigem Token (sonst 404).
$router->get(DomainChecker::PATH, [$domainController, 'verify']);
// --- Oeffentlich: ausschliesslich die Weiterleitung -------------
// Bewusst ganz am Ende und mehrteilig: Kurzlinks duerfen wie
// "g/1AEJ0X90ZmF" aussehen. Alle Routen darueber gewinnen, weil
// der Router in Registrierungsreihenfolge sucht.
$router->get('/{slug:path}', [$redirect, 'handle']);
$router->fallback([$redirect, 'notFound']);
return $router;
}
/**
* @return array<string,string>
*/
private function securityHeaders(Request $request): array
{
$isSecure = $request->isSecure();
$csp = [
"default-src 'self'",
// Skripte nur aus dem eigenen Verzeichnis und - mit
// Integritaetspruefung - Chart.js vom CDN. Kein
// 'unsafe-inline', kein 'unsafe-eval'.
"script-src 'self' https://cdn.jsdelivr.net",
// 'unsafe-inline' gilt hier nur fuer STYLE: die Seiten
// nutzen style-Attribute (Balkenbreiten, Farbfelder).
// Ohne diese Angabe blockiert der Browser sie. Auf
// Skripte hat das keinen Einfluss.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"frame-src 'none'",
"base-uri 'self'",
"object-src 'none'",
];
// Nur sinnvoll, wenn die Seite bereits ueber HTTPS laeuft.
// Auf einer reinen HTTP-Installation wuerde die Anweisung die
// eigenen Stylesheets und Skripte unerreichbar machen.
if ($isSecure) {
$csp[] = 'upgrade-insecure-requests';
}
$headers = [
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'DENY',
'X-Permitted-Cross-Domain-Policies' => 'none',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Cross-Origin-Opener-Policy' => 'same-origin',
'Cross-Origin-Resource-Policy' => 'same-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=(), payment=(), usb=()',
'Content-Security-Policy' => implode('; ', $csp),
];
// HSTS nur ueber HTTPS - sonst ignorieren Browser den Header
// ohnehin. Ohne includeSubDomains, damit nicht versehentlich
// fremde Subdomains derselben Domain mitgesperrt werden.
$maxAge = $this->config->getInt('HSTS_MAX_AGE', 31536000);
if ($maxAge > 0 && $isSecure) {
$headers['Strict-Transport-Security'] = 'max-age=' . $maxAge;
}
return $headers;
}
private function errorResponse(Throwable $e): Response
{
error_log('[urlshort] ' . $e::class . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$detail = $this->config->isDevelopment()
? $e::class . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString()
: null;
try {
$html = $this->view->render('errors/500', ['detail' => $detail], 'layout_plain');
} catch (Throwable) {
$html = '<h1>500 – Interner Fehler</h1>';
}
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(
'<pre style="background:#fee;border:1px solid #c00;padding:1rem;margin:0;overflow:auto">'
. htmlspecialchars(trim($stray), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</pre>' . $response->body(),
$response->status(),
$response->headers(),
);
}
return $response;
}
/**
* Antwort ausliefern und die Verbindung schliessen, damit Nacharbeiten
* (Klick-Protokollierung) den Nutzer nicht warten lassen.
*/
private function finishRequest(): void
{
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
return;
}
if (function_exists('litespeed_finish_request')) {
litespeed_finish_request();
return;
}
// mod_php / built-in server: wenigstens die Puffer leeren.
ignore_user_abort(true);
while (ob_get_level() > 0) {
ob_end_flush();
}
flush();
}
}