From 3ca6a031494a43c92c8655c36368e4cd703890fc Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:39 +0200 Subject: [PATCH] feat(http): Request, Response, Router und Template-Engine Der Router kennt Platzhalter fuer ein Pfadsegment und liefert 405, wenn nur die Methode nicht passt. Response kann Arbeit fuer die Zeit NACH dem Ausliefern vormerken (afterSend) - so verzoegert das Protokollieren eines Klicks die Weiterleitung nicht. BasePath deckt drei Installationsarten ab: Webroot auf public/, Unterverzeichnis und Shared Hosting ohne eigenen Webroot. --- src/Http/BasePath.php | 57 +++++++++++ src/Http/Request.php | 233 ++++++++++++++++++++++++++++++++++++++++++ src/Http/Response.php | 105 +++++++++++++++++++ src/Http/Router.php | 88 ++++++++++++++++ src/View.php | 75 ++++++++++++++ 5 files changed, 558 insertions(+) create mode 100644 src/Http/BasePath.php create mode 100644 src/Http/Request.php create mode 100644 src/Http/Response.php create mode 100644 src/Http/Router.php create mode 100644 src/View.php diff --git a/src/Http/BasePath.php b/src/Http/BasePath.php new file mode 100644 index 0000000..6afec4b --- /dev/null +++ b/src/Http/BasePath.php @@ -0,0 +1,57 @@ + "" + * 2. Installation in /kurz (Webroot) -> "/kurz" + * 3. Shared Hosting ohne eigenen Webroot: alles liegt im Webroot und + * eine .htaccess leitet intern nach public/ um. SCRIPT_NAME lautet + * dann /public/index.php - das "/public" gehoert nicht in die URLs. + */ +final class BasePath +{ + /** + * @param array $server + */ + public static function detect(array $server): string + { + $scriptName = (string) ($server['SCRIPT_NAME'] ?? ''); + $base = rtrim(str_replace('\\', '/', dirname($scriptName)), '/'); + + if ($base === '.' || $base === '/') { + return ''; + } + + // Internes Rewrite nach public/ herausrechnen. + if ($base === '/public') { + return ''; + } + + if (str_ends_with($base, '/public')) { + $base = substr($base, 0, -strlen('/public')); + } + + return $base; + } + + public static function fromGlobals(): string + { + return self::detect($_SERVER); + } + + /** + * Baut eine anwendungsinterne URL. + */ + public static function url(string $path, ?string $base = null): string + { + $base ??= self::fromGlobals(); + + return $base . '/' . ltrim($path, '/'); + } +} diff --git a/src/Http/Request.php b/src/Http/Request.php new file mode 100644 index 0000000..f605022 --- /dev/null +++ b/src/Http/Request.php @@ -0,0 +1,233 @@ + $query + * @param array $post + * @param array $server + * @param array $files + * @param list $trustedProxies IPs/CIDR-Bereiche, deren + * X-Forwarded-*-Header ausgewertet werden duerfen. + */ + public function __construct( + public readonly string $method, + public readonly string $path, + public readonly array $query, + public readonly array $post, + public readonly array $server, + public readonly array $files, + public readonly array $trustedProxies = [], + ) { + } + + /** + * @param list $trustedProxies + */ + public static function fromGlobals(array $trustedProxies = []): self + { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + // Der Pfad kommt bevorzugt aus REQUEST_URI; das Basisverzeichnis + // (Unterordner-Installation) wird abgeschnitten. + $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); + $path = parse_url($uri, PHP_URL_PATH); + $path = is_string($path) ? $path : '/'; + $path = self::stripBasePath(rawurldecode($path), $_SERVER); + + return new self($method, $path, $_GET, $_POST, $_SERVER, $_FILES, $trustedProxies); + } + + /** + * Entfernt das Unterverzeichnis, in dem die Anwendung liegt + * (z. B. /kurz/abc -> /abc bei Installation in /kurz). + * + * @param array $server + */ + public static function stripBasePath(string $path, array $server): string + { + $base = BasePath::detect($server); + + if ($base !== '' && str_starts_with($path, $base . '/')) { + $path = substr($path, strlen($base)); + } elseif ($base !== '' && $path === $base) { + $path = '/'; + } + + // Variante ohne eigenen Webroot: /public/... aus der Anfrage nehmen. + if (str_starts_with($path, '/public/')) { + $path = substr($path, strlen('/public')); + } elseif ($path === '/public') { + $path = '/'; + } + + $path = '/' . ltrim($path, '/'); + + return $path === '/' ? '/' : rtrim($path, '/'); + } + + public function isPost(): bool + { + return $this->method === 'POST'; + } + + public function post(string $key, string $default = ''): string + { + $value = $this->post[$key] ?? null; + + return is_scalar($value) ? trim((string) $value) : $default; + } + + public function query(string $key, string $default = ''): string + { + $value = $this->query[$key] ?? null; + + return is_scalar($value) ? trim((string) $value) : $default; + } + + public function queryInt(string $key, int $default = 0): int + { + $value = $this->query($key); + + return is_numeric($value) ? (int) $value : $default; + } + + public function hasPost(string $key): bool + { + return array_key_exists($key, $this->post); + } + + public function header(string $name): ?string + { + $key = 'HTTP_' . str_replace('-', '_', strtoupper($name)); + $value = $this->server[$key] ?? null; + + return is_string($value) ? $value : null; + } + + public function userAgent(): string + { + return (string) ($this->server['HTTP_USER_AGENT'] ?? ''); + } + + public function referrer(): string + { + return (string) ($this->server['HTTP_REFERER'] ?? ''); + } + + /** + * Client-IP. Wird ausschliesslich fluechtig verwendet + * (Rate-Limiting, optionaler Hash) und nie im Klartext gespeichert. + * + * X-Forwarded-For ist frei setzbar und wird deshalb NUR ausgewertet, + * wenn die Anfrage von einem als vertrauenswuerdig konfigurierten + * Proxy stammt (TRUSTED_PROXIES). Ohne diese Einstellung zaehlt + * allein REMOTE_ADDR - sonst liesse sich die Sperre nach zu vielen + * Fehlanmeldungen durch einen erfundenen Header umgehen. + */ + public function clientIp(): string + { + $remote = (string) ($this->server['REMOTE_ADDR'] ?? ''); + $remote = filter_var($remote, FILTER_VALIDATE_IP) !== false ? $remote : '0.0.0.0'; + + if (!$this->isFromTrustedProxy()) { + return $remote; + } + + $forwarded = (string) ($this->server['HTTP_X_FORWARDED_FOR'] ?? ''); + if ($forwarded === '') { + return $remote; + } + + // Von rechts nach links: der letzte Eintrag, der nicht selbst + // ein bekannter Proxy ist, ist der Client. + foreach (array_reverse(explode(',', $forwarded)) as $candidate) { + $candidate = self::stripPort(trim($candidate)); + + if (filter_var($candidate, FILTER_VALIDATE_IP) === false) { + return $remote; + } + + if (!IpRange::matchesAny($candidate, $this->trustedProxies)) { + return $candidate; + } + } + + return $remote; + } + + public function isFromTrustedProxy(): bool + { + if ($this->trustedProxies === []) { + return false; + } + + $remote = (string) ($this->server['REMOTE_ADDR'] ?? ''); + + return $remote !== '' && IpRange::matchesAny($remote, $this->trustedProxies); + } + + /** + * Laeuft die Anfrage ueber HTTPS? X-Forwarded-Proto wird nur hinter + * einem vertrauenswuerdigen Proxy beruecksichtigt. + */ + public function isSecure(): bool + { + $https = $this->server['HTTPS'] ?? ''; + if (is_string($https) && $https !== '' && strtolower($https) !== 'off') { + return true; + } + + if ((string) ($this->server['SERVER_PORT'] ?? '') === '443') { + return true; + } + + if ($this->isFromTrustedProxy()) { + $proto = strtolower(trim((string) ($this->server['HTTP_X_FORWARDED_PROTO'] ?? ''))); + + return $proto === 'https'; + } + + return false; + } + + /** + * Host-Header, aber nur wenn er wie ein Hostname aussieht. + * Verhindert, dass ein erfundener Host in erzeugte URLs gelangt. + */ + public function host(): string + { + $host = strtolower(trim((string) ($this->server['HTTP_HOST'] ?? ''))); + + if ($host === '' || strlen($host) > 255) { + return ''; + } + + // Erlaubt: name(.name)*(:port) sowie IPv4 und IPv6 in Klammern. + $pattern = '/^(?:\[[0-9a-f:.]+\]|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)(?::\d{1,5})?$/'; + + return preg_match($pattern, $host) === 1 ? $host : ''; + } + + private static function stripPort(string $value): string + { + if (str_starts_with($value, '[')) { + $end = strpos($value, ']'); + + return $end === false ? $value : substr($value, 1, $end - 1); + } + + // Nur abschneiden, wenn genau ein Doppelpunkt vorkommt + // (sonst ist es eine IPv6-Adresse ohne Klammern). + return substr_count($value, ':') === 1 ? explode(':', $value)[0] : $value; + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php new file mode 100644 index 0000000..d2437d1 --- /dev/null +++ b/src/Http/Response.php @@ -0,0 +1,105 @@ + $headers + */ + public function __construct( + private string $body = '', + private int $status = 200, + private array $headers = [], + ) { + } + + public static function html(string $body, int $status = 200): self + { + return new self($body, $status, ['Content-Type' => 'text/html; charset=utf-8']); + } + + public static function json(mixed $data, int $status = 200): self + { + return new self( + json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}', + $status, + ['Content-Type' => 'application/json; charset=utf-8'], + ); + } + + public static function redirect(string $location, int $status = 302): self + { + return new self('', $status, ['Location' => $location]); + } + + /** + * @param array $headers + */ + public static function binary(string $body, string $contentType, array $headers = []): self + { + return new self($body, 200, ['Content-Type' => $contentType] + $headers); + } + + /** @var callable|null */ + private $afterSend = null; + + public function withHeader(string $name, string $value): self + { + $this->headers[$name] = $value; + + return $this; + } + + /** + * Arbeit, die erst NACH dem Ausliefern der Antwort passieren soll + * (z. B. das Protokollieren eines Klicks). Haelt den Redirect kurz. + */ + public function afterSend(callable $callback): self + { + $this->afterSend = $callback; + + return $this; + } + + public function runAfterSend(): void + { + if ($this->afterSend !== null) { + $callback = $this->afterSend; + $this->afterSend = null; + $callback(); + } + } + + public function status(): int + { + return $this->status; + } + + public function body(): string + { + return $this->body; + } + + /** + * @return array + */ + public function headers(): array + { + return $this->headers; + } + + public function send(): void + { + if (!headers_sent()) { + http_response_code($this->status); + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value, true); + } + } + + echo $this->body; + } +} diff --git a/src/Http/Router.php b/src/Http/Router.php new file mode 100644 index 0000000..1661e39 --- /dev/null +++ b/src/Http/Router.php @@ -0,0 +1,88 @@ +,handler:callable}> */ + private array $routes = []; + + /** @var callable|null */ + private $fallback = null; + + public function get(string $pattern, callable $handler): void + { + $this->add('GET', $pattern, $handler); + } + + public function post(string $pattern, callable $handler): void + { + $this->add('POST', $pattern, $handler); + } + + public function fallback(callable $handler): void + { + $this->fallback = $handler; + } + + private function add(string $method, string $pattern, callable $handler): void + { + $params = []; + $regex = preg_replace_callback( + '#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', + static function (array $matches) use (&$params): string { + $params[] = $matches[1]; + + return '([^/]+)'; + }, + $pattern, + ) ?? $pattern; + + $this->routes[] = [ + 'method' => $method, + 'regex' => '#^' . $regex . '$#', + 'params' => $params, + 'handler' => $handler, + ]; + } + + public function dispatch(Request $request): Response + { + $pathMatchedButMethodDidNot = false; + + foreach ($this->routes as $route) { + if (preg_match($route['regex'], $request->path, $matches) !== 1) { + continue; + } + + if ($route['method'] !== $request->method) { + $pathMatchedButMethodDidNot = true; + continue; + } + + array_shift($matches); + $args = []; + foreach ($route['params'] as $index => $name) { + $args[$name] = $matches[$index] ?? ''; + } + + return ($route['handler'])($request, $args); + } + + if ($pathMatchedButMethodDidNot) { + return Response::html('Method Not Allowed', 405); + } + + if ($this->fallback !== null) { + return ($this->fallback)($request, []); + } + + return Response::html('Not Found', 404); + } +} diff --git a/src/View.php b/src/View.php new file mode 100644 index 0000000..629eb93 --- /dev/null +++ b/src/View.php @@ -0,0 +1,75 @@ + */ + private array $shared = []; + + public function __construct(private readonly string $templateDir) + { + // In jedem Template verfuegbar: Escaping und URL-Erzeugung. + $basePath = BasePath::fromGlobals(); + + $this->shared['e'] = static fn (mixed $value): string => self::e($value); + $this->shared['basePath'] = $basePath; + $this->shared['url'] = static fn (string $path): string => $basePath . '/' . ltrim($path, '/'); + } + + public function share(string $key, mixed $value): void + { + $this->shared[$key] = $value; + } + + /** + * Rendert ein Template innerhalb des Layouts. + * + * @param array $data + */ + public function render(string $template, array $data = [], string $layout = 'layout'): string + { + $content = $this->partial($template, $data); + + return $this->partial($layout, $data + ['content' => $content]); + } + + /** + * Rendert ein Template ohne Layout. + * + * @param array $data + */ + public function partial(string $template, array $data = []): string + { + $file = $this->templateDir . '/' . str_replace('.', '/', $template) . '.php'; + if (!is_file($file)) { + throw new RuntimeException(sprintf('Template "%s" nicht gefunden (%s).', $template, $file)); + } + + $vars = $this->shared + $data; + $view = $this; + + ob_start(); + (static function () use ($file, $vars, $view): void { + extract($vars, EXTR_SKIP); + /** @psalm-suppress UnresolvableInclude */ + require $file; + })(); + + return (string) ob_get_clean(); + } + + public static function e(mixed $value): string + { + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +}