- P4-9 [L] logout() rotates the session id before wiping (was regenerate-after-destroy → warning). - P4-10 [L] EventValidator caps device_id/camera_id (64) + zone_id (32) → 400 not a MySQL 'Data too long' 500 the edge retries forever. - P4-11 [L] Request::pathFor collapses leading slashes so '//api/events' isn't misrouted to '/events'. - P5-6 [L] cafe24 build prunes dev caches (.ruff_cache/.impeccable/__pycache__/…) from the package. python + phpunit (86) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
67 lines
3.1 KiB
PHP
67 lines
3.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Http;
|
|
|
|
final class Request
|
|
{
|
|
private array $headers;
|
|
public function __construct(
|
|
private string $method, private string $path, array $headers,
|
|
private string $body, private array $query = [], private array $post = []
|
|
) {
|
|
$this->headers = [];
|
|
foreach ($headers as $k => $v) $this->headers[strtolower($k)] = $v;
|
|
}
|
|
public function method(): string { return $this->method; }
|
|
public function path(): string { return $this->path; }
|
|
public function header(string $name): ?string { return $this->headers[strtolower($name)] ?? null; }
|
|
public function body(): string { return $this->body; }
|
|
public function json(): array { $d = json_decode($this->body, true); return is_array($d) ? $d : []; }
|
|
public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; }
|
|
public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; }
|
|
|
|
/**
|
|
* The URL prefix the app is mounted under, derived from SCRIPT_NAME:
|
|
* '/index.php' => '' (web root), '/move_detect/index.php' => '/move_detect'.
|
|
*
|
|
* SCRIPT_NAME is set by the web server, never by the caller. Deriving the mount point
|
|
* from client-controlled input (a query param, a header) would let anyone relocate the
|
|
* app and route around the prefix.
|
|
*/
|
|
public static function baseFrom(string $scriptName): string
|
|
{
|
|
if ($scriptName === '' || $scriptName[0] !== '/') return '';
|
|
$dir = str_replace('\\', '/', dirname($scriptName));
|
|
return $dir === '/' || $dir === '.' ? '' : rtrim($dir, '/');
|
|
}
|
|
|
|
/**
|
|
* The route path for a REQUEST_URI: query string dropped, mount prefix removed.
|
|
* Routes are declared as '/login', '/api/events', ... so without this every route 404s
|
|
* whenever the app is mounted anywhere but the web root.
|
|
*/
|
|
public static function pathFor(string $requestUri, string $base): string
|
|
{
|
|
// Collapse leading slashes FIRST: parse_url('//api/events') reads 'api' as a
|
|
// scheme-relative host and returns '/events', silently dropping the first segment.
|
|
$requestUri = '/' . ltrim($requestUri, '/');
|
|
$path = parse_url($requestUri, PHP_URL_PATH) ?: '/';
|
|
if ($base !== '' && ($path === $base || str_starts_with($path, $base . '/'))) {
|
|
// Match $base exactly or as a whole segment. A bare str_starts_with($path, $base)
|
|
// would also strip '/move_detected/x' and route it as 'ed/x'.
|
|
$path = substr($path, strlen($base));
|
|
}
|
|
return $path === '' ? '/' : $path;
|
|
}
|
|
|
|
public static function fromGlobals(): self
|
|
{
|
|
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
|
|
$base = self::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? ''));
|
|
return new self(
|
|
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
|
self::pathFor((string)($_SERVER['REQUEST_URI'] ?? '/'), $base),
|
|
$headers, file_get_contents('php://input') ?: '', $_GET, $_POST
|
|
);
|
|
}
|
|
}
|