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 */ 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 = '

500 – Interner Fehler

'; } 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. */ 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(); } }