forked from fa/breadcrumb-the-shire
234 lines
6.7 KiB
PHP
234 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Http;
|
|
|
|
use MintyPHP\Repository\Support\RepoQuery;
|
|
|
|
// Holds per-request metadata (id, channel, method, path, ip) as a static singleton.
|
|
// Must be initialized once at the entry point (web, api, cli) via start().
|
|
final class RequestContext
|
|
{
|
|
/** @var array<string, mixed>|null */
|
|
private static ?array $context = null;
|
|
|
|
public static function start(array $overrides = []): void
|
|
{
|
|
if (self::$context === null) {
|
|
self::$context = self::buildDefaultContext();
|
|
}
|
|
|
|
self::applyOverrides($overrides);
|
|
}
|
|
|
|
public static function ensureStarted(): void
|
|
{
|
|
if (self::$context !== null) {
|
|
return;
|
|
}
|
|
|
|
self::$context = self::buildDefaultContext();
|
|
}
|
|
|
|
// Returns the request ID, generating one if missing. Use this when you need a guaranteed value.
|
|
public static function id(): string
|
|
{
|
|
self::ensureStarted();
|
|
$requestId = trim((string) (self::$context['request_id'] ?? ''));
|
|
if ($requestId === '') {
|
|
$requestId = RepoQuery::uuidV4();
|
|
self::$context['request_id'] = $requestId;
|
|
}
|
|
return $requestId;
|
|
}
|
|
|
|
// Returns the ID only if already set — safe to call before start() (e.g. in error handlers).
|
|
public static function currentId(): ?string
|
|
{
|
|
if (self::$context === null) {
|
|
return null;
|
|
}
|
|
|
|
$requestId = trim((string) (self::$context['request_id'] ?? ''));
|
|
return $requestId !== '' ? $requestId : null;
|
|
}
|
|
|
|
public static function setChannel(string $channel): void
|
|
{
|
|
self::ensureStarted();
|
|
$normalized = self::normalizeChannel($channel);
|
|
if ($normalized !== '') {
|
|
self::$context['channel'] = $normalized;
|
|
}
|
|
}
|
|
|
|
public static function setPath(string $path): void
|
|
{
|
|
self::ensureStarted();
|
|
$path = trim($path);
|
|
if ($path === '') {
|
|
return;
|
|
}
|
|
|
|
self::$context['path'] = substr($path, 0, 255);
|
|
}
|
|
|
|
public static function context(): array
|
|
{
|
|
self::ensureStarted();
|
|
|
|
return self::$context ?? [];
|
|
}
|
|
|
|
public static function requestHeaderValue(): string
|
|
{
|
|
return self::id();
|
|
}
|
|
|
|
// HMAC when a key is configured (e.g. hashing IPs for audit logs) — plain SHA256 as fallback.
|
|
public static function hashValue(string $value): string
|
|
{
|
|
$secret = defined('APP_CRYPTO_KEY') ? trim((string) APP_CRYPTO_KEY) : '';
|
|
if ($secret === '') {
|
|
return hash('sha256', $value);
|
|
}
|
|
|
|
return hash_hmac('sha256', $value, $secret);
|
|
}
|
|
|
|
public static function resetForTests(): void
|
|
{
|
|
self::$context = null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function buildDefaultContext(): array
|
|
{
|
|
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
|
|
if ($method === '') {
|
|
$method = PHP_SAPI === 'cli' ? 'CLI' : 'GET';
|
|
}
|
|
|
|
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
|
$path = parse_url($uri, PHP_URL_PATH);
|
|
$path = is_string($path) ? trim($path) : '';
|
|
if ($path === '' && PHP_SAPI === 'cli') {
|
|
$path = (string) ($_SERVER['argv'][0] ?? 'cli');
|
|
}
|
|
|
|
$requestId = self::requestIdFromHeader();
|
|
if ($requestId === null) {
|
|
$requestId = RepoQuery::uuidV4();
|
|
}
|
|
|
|
return [
|
|
'request_id' => $requestId,
|
|
'channel' => self::detectChannel($path),
|
|
'method' => substr($method, 0, 8),
|
|
'path' => substr($path, 0, 255),
|
|
'ip' => self::normalizeIp((string) ($_SERVER['REMOTE_ADDR'] ?? '')),
|
|
'user_agent' => self::normalizeUserAgent((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')),
|
|
];
|
|
}
|
|
|
|
private static function applyOverrides(array $overrides): void
|
|
{
|
|
foreach ($overrides as $key => $value) {
|
|
if (!is_string($key)) {
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'request_id') {
|
|
$requestId = trim((string) $value);
|
|
if (self::isValidUuid($requestId)) {
|
|
self::$context['request_id'] = $requestId;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'channel') {
|
|
$channel = self::normalizeChannel((string) $value);
|
|
if ($channel !== '') {
|
|
self::$context['channel'] = $channel;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'method') {
|
|
$method = strtoupper(trim((string) $value));
|
|
if ($method !== '') {
|
|
self::$context['method'] = substr($method, 0, 8);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($key === 'path') {
|
|
$path = trim((string) $value);
|
|
if ($path !== '') {
|
|
self::$context['path'] = substr($path, 0, 255);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function detectChannel(string $path): string
|
|
{
|
|
if (PHP_SAPI === 'cli') {
|
|
return 'cli';
|
|
}
|
|
if (defined('MINTY_API_REQUEST')) {
|
|
return 'api';
|
|
}
|
|
|
|
$normalizedPath = ltrim(strtolower(trim($path)), '/');
|
|
if (str_starts_with($normalizedPath, 'api/')) {
|
|
return 'api';
|
|
}
|
|
|
|
return 'web';
|
|
}
|
|
|
|
private static function normalizeChannel(string $channel): string
|
|
{
|
|
$channel = strtolower(trim($channel));
|
|
return in_array($channel, ['web', 'api', 'scheduler', 'cli'], true) ? $channel : '';
|
|
}
|
|
|
|
// Accept a caller-supplied request ID for cross-service trace correlation.
|
|
// Only trusted if it's a valid UUID format — arbitrary strings are ignored.
|
|
private static function requestIdFromHeader(): ?string
|
|
{
|
|
$header = trim((string) ($_SERVER['HTTP_X_REQUEST_ID'] ?? ''));
|
|
if (self::isValidUuid($header)) {
|
|
return strtolower($header);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function isValidUuid(string $value): bool
|
|
{
|
|
return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1;
|
|
}
|
|
|
|
private static function normalizeIp(string $ip): string
|
|
{
|
|
$ip = trim($ip);
|
|
if ($ip === '') {
|
|
return '';
|
|
}
|
|
return substr($ip, 0, 45);
|
|
}
|
|
|
|
private static function normalizeUserAgent(string $userAgent): string
|
|
{
|
|
$userAgent = trim($userAgent);
|
|
if ($userAgent === '') {
|
|
return '';
|
|
}
|
|
return substr($userAgent, 0, 255);
|
|
}
|
|
}
|