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.
234 lines
6.9 KiB
PHP
234 lines
6.9 KiB
PHP
<?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;
|
|
}
|
|
}
|