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.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
/**
|
||||
* Ermittelt das Unterverzeichnis, in dem die Anwendung erreichbar ist.
|
||||
*
|
||||
* Deckt drei Faelle ab:
|
||||
* 1. Webroot zeigt auf /public -> ""
|
||||
* 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<string,mixed> $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, '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use App\Support\IpRange;
|
||||
|
||||
/**
|
||||
* Minimaler Request-Wrapper um die Superglobals.
|
||||
*/
|
||||
final class Request
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $query
|
||||
* @param array<string,mixed> $post
|
||||
* @param array<string,mixed> $server
|
||||
* @param array<string,mixed> $files
|
||||
* @param list<string> $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<string> $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<string,mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
final class Response
|
||||
{
|
||||
/**
|
||||
* @param array<string,string> $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<string,string> $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<string,string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
/**
|
||||
* Sehr einfacher Router. Platzhalter werden als {name} notiert und
|
||||
* matchen ein Pfadsegment.
|
||||
*/
|
||||
final class Router
|
||||
{
|
||||
/** @var list<array{method:string,regex:string,params:list<string>,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Http\BasePath;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Sehr schlanke Template-Engine auf Basis von PHP-Dateien.
|
||||
* Ausgabe wird konsequent ueber e() escaped.
|
||||
*/
|
||||
final class View
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
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<string,mixed> $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<string,mixed> $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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user