refactor: rename lib/ to core/ for clearer core-module separation

Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 23:20:05 +02:00
parent 7c10fadcb9
commit 0e86925464
371 changed files with 191 additions and 192 deletions

100
core/Http/AccessControl.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\I18n;
use MintyPHP\Router;
/**
* Handles public path detection and authentication guards.
*/
class AccessControl
{
/** Prefixes that are always public */
private const ALWAYS_PUBLIC_PREFIXES = [
'branding/',
'auth/tenant-avatar-file',
'flash/',
'auth/microsoft/',
'api/',
];
private array $configuredPublicPaths;
private IntendedUrlService $intendedUrlService;
public function __construct(array $publicPaths, IntendedUrlService $intendedUrlService)
{
$normalizedPaths = [];
foreach ($publicPaths as $path) {
$rawPath = trim((string) $path);
if ($rawPath === '') {
continue;
}
$normalizedPaths[] = $rawPath === '/' ? '/' : trim($rawPath, '/');
}
$this->configuredPublicPaths = array_values(array_unique($normalizedPaths));
$this->intendedUrlService = $intendedUrlService;
}
/**
* Check if a path is publicly accessible (no authentication required).
*/
public function isPublicPath(string $path): bool
{
$normalizedPath = $this->normalizePath($path);
// Root path check
if ($normalizedPath === '/') {
return in_array('/', $this->configuredPublicPaths, true);
}
// Check configured public paths
if (in_array($normalizedPath, $this->configuredPublicPaths, true)) {
return true;
}
// Check always-public prefixes
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
if (str_starts_with($normalizedPath, $prefix)) {
return true;
}
}
return false;
}
/**
* Redirect to login if the path requires authentication and user is not logged in.
*
* @return bool True if access is allowed, false if redirected
*/
/**
* Redirect to login if the path requires authentication and user is not logged in.
* Appends ?return_to= so the user is redirected back after login.
*
* @return bool True if access is allowed, false if redirected
*/
public function requireAuthOrRedirect(string $path, bool $isLoggedIn): bool
{
if ($this->isPublicPath($path) || $isLoggedIn) {
return true;
}
$locale = I18n::$locale ?? I18n::$defaultLocale;
$loginPath = Request::withLocale('login', $locale);
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
$loginTarget = $this->intendedUrlService->buildLoginRedirect($loginPath, $requestUri);
Router::redirect($loginTarget);
return false;
}
private function normalizePath(string $path): string
{
$normalized = trim($path, '/');
return $normalized === '' ? '/' : $normalized;
}
}

286
core/Http/ApiAuth.php Normal file
View File

@@ -0,0 +1,286 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Repository\Access\RolePermissionRepository;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserTenantContextService;
// Static facade for API authentication — state is set once per request during ApiBootstrap::init().
// Dependencies are injected as lazy resolvers so services are only instantiated when actually needed.
class ApiAuth
{
/** @var (callable(): TenantScopeService)|null */
private static $authScopeGatewayResolver = null;
/** @var (callable(): ApiTokenService)|null */
private static $apiTokenServiceResolver = null;
/** @var (callable(): UserRoleRepository)|null */
private static $userRoleRepositoryResolver = null;
/** @var (callable(): RolePermissionRepository)|null */
private static $rolePermissionRepositoryResolver = null;
/** @var (callable(): UserTenantContextService)|null */
private static $userTenantContextServiceResolver = null;
/** @var (callable(): AuthorizationService)|null */
private static $authorizationServiceResolver = null;
private static ?array $currentUser = null;
private static ?array $currentPermissions = null;
private static ?int $currentTenantId = null;
private static ?int $tokenTenantId = null;
private static ?array $currentTokenRecord = null;
private static bool $authenticated = false;
public static function configure(
callable $authScopeGatewayResolver,
callable $apiTokenServiceResolver,
callable $userRoleRepositoryResolver,
callable $rolePermissionRepositoryResolver,
callable $userTenantContextServiceResolver,
callable $authorizationServiceResolver
): void {
self::$authScopeGatewayResolver = $authScopeGatewayResolver;
self::$apiTokenServiceResolver = $apiTokenServiceResolver;
self::$userRoleRepositoryResolver = $userRoleRepositoryResolver;
self::$rolePermissionRepositoryResolver = $rolePermissionRepositoryResolver;
self::$userTenantContextServiceResolver = $userTenantContextServiceResolver;
self::$authorizationServiceResolver = $authorizationServiceResolver;
}
/**
* Extract the full Bearer token from the Authorization header.
*/
public static function extractBearerToken(): string
{
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
if ($header === '') {
// Nginx with PHP-FPM may expose the header under this key instead
$header = trim((string) ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
}
if ($header === '' || stripos($header, 'Bearer ') !== 0) {
return '';
}
return trim(substr($header, 7));
}
/**
* Attempt to authenticate from Authorization: Bearer header.
*/
public static function authenticate(): bool
{
self::$currentUser = null;
self::$currentPermissions = null;
self::$currentTenantId = null;
self::$tokenTenantId = null;
self::$currentTokenRecord = null;
self::$authenticated = false;
$bearerToken = self::extractBearerToken();
if ($bearerToken === '') {
return false;
}
$scopeGateway = self::scopeGateway();
$result = self::apiTokenService()->validate($bearerToken);
if ($result === null) {
return false;
}
$user = $result['user'];
$userId = (int) ($user['id'] ?? 0);
$userRoleRepository = self::userRoleRepository();
$rolePermissionRepository = self::rolePermissionRepository();
$userTenantContextService = self::userTenantContextService();
// Load permissions directly (bypass session cache)
$roleIds = $userRoleRepository->listRoleIdsByUserId($userId);
$permissions = $rolePermissionRepository->listPermissionKeysByRoleIds($roleIds);
// Resolve tenant context: a token may be scoped to a specific tenant,
// otherwise fall back to the user's active tenant context.
$tokenTenantId = $result['tenant_id'];
if ($tokenTenantId !== null) {
// Verify the token's tenant is still assigned to this user
$userTenantIds = $scopeGateway->getUserTenantIds($userId);
if (!in_array($tokenTenantId, $userTenantIds, true)) {
return false;
}
$currentTenantId = $tokenTenantId;
self::$tokenTenantId = $tokenTenantId;
} else {
$currentTenantId = $userTenantContextService->getCurrentTenantId($userId);
self::$tokenTenantId = null;
}
self::$currentUser = $user;
self::$currentPermissions = $permissions;
self::$currentTenantId = $currentTenantId;
self::$currentTokenRecord = $result['token_record'];
self::$authenticated = true;
return true;
}
public static function isAuthenticated(): bool
{
return self::$authenticated;
}
public static function user(): ?array
{
return self::$currentUser;
}
public static function userId(): int
{
return (int) (self::$currentUser['id'] ?? 0);
}
public static function permissions(): array
{
return self::$currentPermissions ?? [];
}
public static function can(string $ability, array $context = []): bool
{
$decision = self::authorizationService()->authorize($ability, [
'actor_user_id' => self::userId(),
'scoped_tenant_id' => self::scopedTenantId(),
...$context,
]);
return $decision->isAllowed();
}
// Effective tenant for data filtering — may come from token scope or user session.
public static function tenantId(): ?int
{
return self::$currentTenantId;
}
// Non-null only when the token itself was issued for a specific tenant.
// Used to restrict access in token-scoped requests (e.g. tenant integrations).
public static function scopedTenantId(): ?int
{
return self::$tokenTenantId;
}
public static function tokenRecord(): ?array
{
return self::$currentTokenRecord;
}
public static function tokenId(): ?int
{
$id = (int) (self::$currentTokenRecord['id'] ?? 0);
return $id > 0 ? $id : null;
}
public static function isTenantScopedToken(): bool
{
return (self::$tokenTenantId ?? 0) > 0;
}
/**
* Require both tenant-scope and token-tenant access for a resource.
*/
public static function requireResourceAccess(string $resource, int $resourceId): void
{
$scopeGateway = self::scopeGateway();
if (!$scopeGateway->canAccess($resource, $resourceId, self::userId())) {
ApiResponse::notFound();
}
self::requireTokenTenantAccess($resource, $resourceId);
}
public static function requireTokenTenantAccess(string $resource, int $resourceId): void
{
if (!self::isTenantScopedToken()) {
return;
}
$tenantId = (int) (self::$tokenTenantId ?? 0);
if ($tenantId <= 0) {
ApiResponse::forbidden();
}
$scopeGateway = self::scopeGateway();
if (!$scopeGateway->resourceBelongsToTenant($resource, $resourceId, $tenantId)) {
// 404 instead of 403 — don't reveal that the resource exists in another tenant
ApiResponse::notFound();
}
}
/**
* Check if the current user can self-manage API tokens.
*/
public static function canSelfManageTokens(): bool
{
$decision = self::authorizationService()->authorize(
\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_API_TOKENS_SELF_MANAGE,
[
'actor_user_id' => self::userId(),
'scoped_tenant_id' => self::scopedTenantId(),
]
);
return $decision->isAllowed();
}
public static function requireSelfManageTokens(): void
{
if (!self::canSelfManageTokens()) {
ApiResponse::forbidden('api_tokens_self_manage_forbidden');
}
}
private static function scopeGateway(): TenantScopeService
{
return self::resolveDependency(self::$authScopeGatewayResolver, TenantScopeService::class, 'ApiAuth');
}
private static function apiTokenService(): ApiTokenService
{
return self::resolveDependency(self::$apiTokenServiceResolver, ApiTokenService::class, 'ApiAuth');
}
private static function userRoleRepository(): UserRoleRepository
{
return self::resolveDependency(self::$userRoleRepositoryResolver, UserRoleRepository::class, 'ApiAuth');
}
private static function rolePermissionRepository(): RolePermissionRepository
{
return self::resolveDependency(self::$rolePermissionRepositoryResolver, RolePermissionRepository::class, 'ApiAuth');
}
private static function userTenantContextService(): UserTenantContextService
{
return self::resolveDependency(self::$userTenantContextServiceResolver, UserTenantContextService::class, 'ApiAuth');
}
private static function authorizationService(): AuthorizationService
{
return self::resolveDependency(self::$authorizationServiceResolver, AuthorizationService::class, 'ApiAuth');
}
private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed
{
if (!is_callable($resolver)) {
throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass);
}
$service = $resolver();
if (!$service instanceof $expectedClass) {
throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass);
}
return $service;
}
}

323
core/Http/ApiBootstrap.php Normal file
View File

@@ -0,0 +1,323 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
// Entry point bootstrapper called once at the top of every API action.
// Handles: request context, CORS, audit start, rate limiting, and optional Bearer auth.
class ApiBootstrap
{
// Two separate rate-limit buckets: one per IP (broad), one per token+IP (fine-grained).
private const API_RATE_SCOPE_TOKEN = 'api.token_ip';
private const API_RATE_SCOPE_IP = 'api.ip';
private const API_RATE_LIMIT_TOKEN = 300;
private const API_RATE_LIMIT_IP = 120;
private const API_RATE_WINDOW_SECONDS = 60;
private const API_RATE_BLOCK_SECONDS = 120;
private static bool $initialized = false;
private static bool $shutdownRegistered = false;
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
private static $apiAuditServiceResolver = null;
/** @var (callable(): SettingsApiPolicyGateway)|null */
private static $settingsApiPolicyGatewayResolver = null;
/** @var (callable(): RateLimiterService)|null */
private static $rateLimiterServiceResolver = null;
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
private static $apiSystemAuditReporterResolver = null;
public static function configure(
?callable $apiAuditServiceResolver,
callable $settingsApiPolicyGatewayResolver,
callable $rateLimiterServiceResolver,
?callable $apiSystemAuditReporterResolver
): void {
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
self::$settingsApiPolicyGatewayResolver = $settingsApiPolicyGatewayResolver;
self::$rateLimiterServiceResolver = $rateLimiterServiceResolver;
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
}
/**
* Initialize the API request context.
*
* Call this at the top of every API page action.
* Sets MINTY_ALLOW_OUTPUT, CORS headers and optional Bearer auth.
*/
public static function init(bool $requireAuth = true): void
{
if (self::$initialized) {
return;
}
self::$initialized = true;
if (!defined('MINTY_ALLOW_OUTPUT')) {
define('MINTY_ALLOW_OUTPUT', true);
}
RequestContext::start([
'channel' => 'api',
'method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')),
'path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH),
]);
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
self::registerShutdownHandler();
self::tryStartAudit();
self::startSystemAuditReporter();
self::setCorsHeaders();
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
die();
}
self::enforceRateLimit();
if ($requireAuth) {
$authenticated = ApiAuth::authenticate();
if (!$authenticated) {
ApiResponse::unauthorized();
}
}
}
private static function setCorsHeaders(): void
{
$rawOrigin = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($rawOrigin === '') {
return;
}
$origin = self::normalizeOrigin($rawOrigin);
if ($origin === null) {
return;
}
$allowedOrigins = array_fill_keys(self::settingsApiPolicy()->getApiCorsAllowedOrigins(), true);
if (!isset($allowedOrigins[$origin])) {
return;
}
header('Vary: Origin');
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, Accept');
header('Access-Control-Max-Age: 86400');
}
// Guarantees audit records are written even on uncaught fatal errors.
private static function registerShutdownHandler(): void
{
if (self::$shutdownRegistered) {
return;
}
self::$shutdownRegistered = true;
register_shutdown_function(static function (): void {
$error = error_get_last();
if (!is_array($error)) {
$statusCode = (int) http_response_code();
if ($statusCode <= 0) {
$statusCode = 200;
}
self::tryFinishAudit($statusCode);
self::finishSystemAuditReporter($statusCode);
return;
}
$type = (int) $error['type'];
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if (!in_array($type, $fatalTypes, true)) {
$statusCode = (int) http_response_code();
if ($statusCode <= 0) {
$statusCode = 200;
}
self::tryFinishAudit($statusCode);
self::finishSystemAuditReporter($statusCode);
return;
}
$statusCode = (int) http_response_code();
if ($statusCode < 400) {
$statusCode = 500;
}
self::tryFinishAudit($statusCode, 'fatal_error');
self::finishSystemAuditReporter($statusCode, 'fatal_error');
});
}
private static function normalizeOrigin(string $origin): ?string
{
$origin = rtrim(trim($origin), '/');
if ($origin === '') {
return null;
}
$parsed = parse_url($origin);
if (!is_array($parsed)) {
return null;
}
$scheme = strtolower((string) ($parsed['scheme'] ?? ''));
$host = strtolower((string) ($parsed['host'] ?? ''));
$port = isset($parsed['port']) ? (int) $parsed['port'] : null;
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
return null;
}
if (
isset($parsed['path'])
|| isset($parsed['query'])
|| isset($parsed['fragment'])
|| isset($parsed['user'])
|| isset($parsed['pass'])
) {
return null;
}
$defaultPort = ($scheme === 'https') ? 443 : 80;
$portSuffix = ($port !== null && $port !== $defaultPort) ? ':' . $port : '';
return $scheme . '://' . $host . $portSuffix;
}
private static function enforceRateLimit(): void
{
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
if ($ip === '') {
$ip = 'unknown';
}
$ipResult = self::rateLimiter()->hit(
self::API_RATE_SCOPE_IP,
$ip,
self::API_RATE_LIMIT_IP,
self::API_RATE_WINDOW_SECONDS,
self::API_RATE_BLOCK_SECONDS
);
if (!($ipResult['allowed'] ?? true)) {
ApiResponse::tooManyRequests(max(1, (int) ($ipResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
}
$selector = self::extractBearerSelector();
if ($selector === '') {
return;
}
$tokenResult = self::rateLimiter()->hit(
self::API_RATE_SCOPE_TOKEN,
strtolower($selector) . '|' . $ip,
self::API_RATE_LIMIT_TOKEN,
self::API_RATE_WINDOW_SECONDS,
self::API_RATE_BLOCK_SECONDS
);
if (!($tokenResult['allowed'] ?? true)) {
ApiResponse::tooManyRequests(max(1, (int) ($tokenResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
}
}
// API tokens are formatted as "selector:verifier". The selector (24-char hex) is used
// as the rate-limit key so a single token can't bypass the per-IP bucket by rotating IPs.
private static function extractBearerSelector(): string
{
$token = ApiAuth::extractBearerToken();
if ($token === '') {
return '';
}
$parts = explode(':', $token, 2);
$selector = trim((string) ($parts[0] ?? ''));
if ($selector === '' || !preg_match('/^[a-f0-9]{24}$/i', $selector)) {
return '';
}
return $selector;
}
private static function settingsApiPolicy(): SettingsApiPolicyGateway
{
return self::resolveDependency(
self::$settingsApiPolicyGatewayResolver,
SettingsApiPolicyGateway::class,
'ApiBootstrap'
);
}
private static function rateLimiter(): RateLimiterService
{
return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap');
}
private static function tryStartAudit(): void
{
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'startRequestContext')) {
$service->startRequestContext();
}
}
} catch (\Throwable) {
// fail-open
}
}
private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void
{
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}
}
private static function startSystemAuditReporter(): void
{
try {
if (is_callable(self::$apiSystemAuditReporterResolver)) {
$service = (self::$apiSystemAuditReporterResolver)();
if (is_object($service) && method_exists($service, 'start')) {
$service->start();
}
}
} catch (\Throwable) {
// fail-open
}
}
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
{
try {
if (is_callable(self::$apiSystemAuditReporterResolver)) {
$service = (self::$apiSystemAuditReporterResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}
}
private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed
{
if (!is_callable($resolver)) {
throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass);
}
$service = $resolver();
if (!$service instanceof $expectedClass) {
throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass);
}
return $service;
}
}

297
core/Http/ApiResponse.php Normal file
View File

@@ -0,0 +1,297 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Http\Input\FormErrors;
use MintyPHP\Service\Access\AuthorizationService;
class ApiResponse
{
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
private static $apiAuditServiceResolver = null;
/** @var (callable(): AuthorizationService)|null */
private static $authorizationServiceResolver = null;
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
private static $apiSystemAuditReporterResolver = null;
public static function configure(
?callable $apiAuditServiceResolver,
callable $authorizationServiceResolver,
?callable $apiSystemAuditReporterResolver
): void {
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
self::$authorizationServiceResolver = $authorizationServiceResolver;
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
}
public static function success(array $data = [], int $status = 200): never
{
self::send($data, $status);
}
public static function created(array $data = []): never
{
self::success($data, 201);
}
public static function noContent(): never
{
self::send(null, 204);
}
public static function error(string $error, int $status = 400, array $extra = []): never
{
$body = self::buildErrorBody($error, $extra);
self::send($body, $status, $error);
}
public static function unauthorized(): never
{
self::error('unauthorized', 401);
}
public static function forbidden(string $detail = 'forbidden'): never
{
self::error($detail, 403);
}
public static function notFound(string $detail = 'not_found'): never
{
self::error($detail, 404);
}
public static function methodNotAllowed(): never
{
self::error('method_not_allowed', 405);
}
public static function validationError(array $errors): never
{
self::error('validation_error', 422, ['errors' => $errors]);
}
public static function validationFromFormErrors(FormErrors $errors): never
{
self::validationError($errors->toArray());
}
public static function tooManyRequests(int $retryAfter = 60): never
{
$retryAfter = max(1, $retryAfter);
self::send(
self::buildErrorBody('rate_limit_exceeded', ['retry_after' => $retryAfter]),
429,
'rate_limit_exceeded',
['Retry-After: ' . $retryAfter]
);
}
/**
* Transform a service result (['ok' => bool, ...] pattern) into an API response.
*/
public static function fromServiceResult(array $result, int $successStatus = 200): never
{
if ($result['ok'] ?? false) {
$data = $result;
unset($data['ok']);
self::success($data, $successStatus);
}
$status = (int) ($result['status'] ?? 0);
if ($status < 400) {
$status = 422;
}
$error = (string) ($result['error'] ?? 'operation_failed');
$errors = $result['errors'] ?? [];
$extra = [];
if ($errors) {
$extra['errors'] = $errors;
}
self::error($error, $status, $extra);
}
/**
* Read JSON request body into an array.
*/
public static function readJsonBody(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return [];
}
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
/**
* Require specific HTTP method(s).
*/
public static function requireMethod(string ...$methods): void
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($method, $methods, true)) {
self::methodNotAllowed();
}
}
/**
* Require API authentication.
*/
public static function requireAuth(): void
{
if (!ApiAuth::isAuthenticated()) {
self::unauthorized();
}
}
public static function requireAbility(string $ability, array $context = []): void
{
self::requireAuth();
$decision = self::authorizationService()->authorize($ability, [
'actor_user_id' => ApiAuth::userId(),
'scoped_tenant_id' => ApiAuth::scopedTenantId(),
...$context,
]);
if (!$decision->isAllowed()) {
self::forbidden();
}
}
private static function send(?array $body, int $status, ?string $errorCode = null, array $headers = []): never
{
$requestId = self::requestId();
http_response_code($status);
header('X-Request-Id: ' . $requestId);
foreach ($headers as $headerLine) {
header($headerLine);
}
if ($body === null) {
self::finishSystemAuditReporter($status, $errorCode);
self::tryFinishAudit($status, $errorCode);
die();
}
if (!array_key_exists('request_id', $body)) {
$body['request_id'] = $requestId;
}
$json = json_encode($body, JSON_UNESCAPED_UNICODE);
if (!is_string($json)) {
$status = 500;
http_response_code($status);
$errorCode = $errorCode ?: 'serialization_error';
$fallbackBody = self::buildErrorBody('serialization_error');
$fallbackBody['request_id'] = $requestId;
$json = json_encode($fallbackBody, JSON_UNESCAPED_UNICODE)
?: '{"ok":false,"error_code":"serialization_error","request_id":"unknown","details":{}}';
}
header('Content-Type: application/json; charset=utf-8');
self::finishSystemAuditReporter($status, $errorCode);
self::tryFinishAudit($status, $errorCode);
die($json);
}
private static function requestId(): string
{
$requestId = RequestContext::currentId();
if (is_string($requestId) && trim($requestId) !== '') {
return trim($requestId);
}
$requestId = self::tryGetAuditRequestId();
if (is_string($requestId) && trim($requestId) !== '') {
return trim($requestId);
}
return RequestContext::id();
}
private static function buildErrorBody(string $errorCode, array $details = []): array
{
$normalizedErrorCode = trim($errorCode);
if ($normalizedErrorCode === '') {
$normalizedErrorCode = 'unknown_error';
}
$normalizedDetails = self::normalizeDetails($details);
return [
'ok' => false,
'error_code' => $normalizedErrorCode,
'details' => $normalizedDetails,
];
}
// Empty details must be stdClass so json_encode produces {} instead of [].
private static function normalizeDetails(array $details): array|\stdClass
{
if ($details === []) {
return new \stdClass();
}
if (array_is_list($details)) {
return ['items' => $details];
}
return $details;
}
private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void
{
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}
}
private static function tryGetAuditRequestId(): ?string
{
try {
if (is_callable(self::$apiAuditServiceResolver)) {
$service = (self::$apiAuditServiceResolver)();
if (is_object($service) && method_exists($service, 'currentRequestId')) {
return $service->currentRequestId();
}
}
} catch (\Throwable) {
// fail-open
}
return null;
}
private static function authorizationService(): AuthorizationService
{
if (!is_callable(self::$authorizationServiceResolver)) {
throw new \RuntimeException('ApiResponse is not configured for dependency: ' . AuthorizationService::class);
}
$service = (self::$authorizationServiceResolver)();
if (!$service instanceof AuthorizationService) {
throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . AuthorizationService::class);
}
return $service;
}
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
{
try {
if (is_callable(self::$apiSystemAuditReporterResolver)) {
$service = (self::$apiSystemAuditReporterResolver)();
if (is_object($service) && method_exists($service, 'finish')) {
$service->finish($statusCode, $errorCode);
}
}
} catch (\Throwable) {
// fail-open
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace MintyPHP\Http;
/**
* Detects static asset requests that should bypass locale redirects.
*/
class AssetDetector
{
/** File extensions that indicate static assets */
private const ASSET_EXTENSIONS = [
'css', 'js', 'mjs', 'map',
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp',
'woff', 'woff2', 'ttf', 'eot',
'webmanifest',
];
/** Path prefixes that indicate static asset directories */
private const ASSET_PREFIXES = [
'vendor/',
'css/',
'js/',
'favicon/',
'brand/',
];
/**
* Check if the given path is a static asset request.
*/
public static function isAssetRequest(string $path): bool
{
if ($path === '') {
return false;
}
// Check file extension
if (self::hasAssetExtension($path)) {
return true;
}
// Check directory prefix
foreach (self::ASSET_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return true;
}
}
return false;
}
private static function hasAssetExtension(string $path): bool
{
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return $extension !== '' && in_array($extension, self::ASSET_EXTENSIONS, true);
}
}

34
core/Http/CookieStore.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Http;
class CookieStore implements CookieStoreInterface
{
public function get(string $name): string
{
return (string) ($_COOKIE[$name] ?? '');
}
/**
* @param array<string, mixed> $options
*/
public function set(string $name, string $value, array $options = []): void
{
setcookie($name, $value, $options);
$_COOKIE[$name] = $value;
}
/**
* @param array<string, mixed> $options
*/
public function remove(string $name, array $options = []): void
{
$removeOptions = ['expires' => time() - 3600, 'path' => '/'];
foreach ($options as $key => $value) {
$removeOptions[$key] = $value;
}
setcookie($name, '', $removeOptions);
unset($_COOKIE[$name]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace MintyPHP\Http;
interface CookieStoreInterface
{
public function get(string $name): string;
/**
* @param array<string, mixed> $options
*/
public function set(string $name, string $value, array $options = []): void;
/**
* @param array<string, mixed> $options
*/
public function remove(string $name, array $options = []): void;
}

View File

@@ -0,0 +1,349 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Debugger;
final class ErrorDataCollector
{
private const CODE_CONTEXT_LINES = 10;
private const MAX_TRACE_FRAMES = 50;
private const REDACTED_HEADER_PATTERNS = [
'authorization',
'cookie',
'x-api-key',
];
private const REDACTED_KEY_PATTERNS = [
'password',
'secret',
'token',
'key',
'csrf',
];
private const SAFE_ENV_KEYS = [
'APP_DEBUG',
'APP_ENV',
'APP_NAME',
'APP_TIMEZONE',
'APP_LOCALE',
'APP_URL',
'APP_VENDOR_PHP_ISSUES_MODE',
'TENANT_SCOPE_STRICT',
];
/**
* @return array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>}
*/
public static function collect(\Throwable $exception): array
{
return [
'exception' => self::collectException($exception),
'request' => self::collectRequest(),
'environment' => self::collectEnvironment(),
'queries' => self::collectQueries(),
];
}
/**
* @return array<string, mixed>
*/
private static function collectException(\Throwable $exception): array
{
$data = [
'class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => self::collectStackFrames($exception),
];
$previous = $exception->getPrevious();
if ($previous !== null) {
$data['previous'] = self::collectException($previous);
}
return $data;
}
/**
* @return list<array<string, mixed>>
*/
private static function collectStackFrames(\Throwable $exception): array
{
$frames = [];
// Frame 0 is the exception origin itself.
$frames[] = [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'function' => null,
'class' => null,
'code_snippet' => self::extractCodeSnippet($exception->getFile(), $exception->getLine()),
];
foreach (array_slice($exception->getTrace(), 0, self::MAX_TRACE_FRAMES) as $frame) {
$file = $frame['file'] ?? null;
$line = $frame['line'] ?? null;
$function = $frame['function'];
$class = $frame['class'] ?? null;
$frames[] = [
'file' => $file,
'line' => $line,
'function' => $function,
'class' => $class,
'args_summary' => self::summarizeArgs($frame['args'] ?? []),
'code_snippet' => ($file !== null && $line !== null)
? self::extractCodeSnippet($file, $line)
: [],
];
}
return $frames;
}
/**
* @return array<int, string> Line number => code line
*/
private static function extractCodeSnippet(string $file, int $line): array
{
try {
if (!is_file($file) || !is_readable($file)) {
return [];
}
$lines = @file($file, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return [];
}
$start = max(0, $line - self::CODE_CONTEXT_LINES - 1);
$end = min(count($lines), $line + self::CODE_CONTEXT_LINES);
$snippet = [];
for ($i = $start; $i < $end; $i++) {
$snippet[$i + 1] = $lines[$i];
}
return $snippet;
} catch (\Throwable) {
return [];
}
}
/**
* @param list<mixed> $args
* @return list<string>
*/
private static function summarizeArgs(array $args): array
{
$summary = [];
foreach ($args as $arg) {
$summary[] = match (true) {
is_null($arg) => 'null',
is_bool($arg) => $arg ? 'true' : 'false',
is_int($arg) => 'int',
is_float($arg) => 'float',
is_string($arg) => 'string(' . strlen($arg) . ')',
is_array($arg) => 'array(' . count($arg) . ')',
is_object($arg) => get_class($arg),
is_resource($arg) => 'resource',
default => 'unknown',
};
}
return $summary;
}
/**
* @return array<string, mixed>
*/
private static function collectRequest(): array
{
$data = [
'request_id' => null,
'channel' => 'web',
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
'url' => ($_SERVER['REQUEST_URI'] ?? ''),
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
'headers' => self::collectHeaders(),
'get' => $_GET,
'post' => self::redactParams($_POST),
'session_keys' => [],
'user_id' => null,
'tenant_id' => null,
];
// Safe request context access.
try {
$data['request_id'] = RequestContext::currentId() ?? RequestContext::id();
} catch (\Throwable) {
// RequestContext may not be initialized.
}
try {
$ctx = RequestContext::context();
$data['channel'] = $ctx['channel'] ?? 'web';
} catch (\Throwable) {
}
// Session data — keys and types only, never values.
if (session_status() === PHP_SESSION_ACTIVE) {
$data['session_keys'] = self::describeSessionKeys($_SESSION ?? []);
$data['user_id'] = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null;
$data['tenant_id'] = $_SESSION['current_tenant']['id'] ?? $_SESSION['tenant_id'] ?? null;
}
return $data;
}
/**
* @return array<string, string>
*/
private static function collectHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (!str_starts_with($key, 'HTTP_')) {
continue;
}
$name = strtolower(str_replace('_', '-', substr($key, 5)));
$headers[$name] = self::shouldRedactHeader($name) ? '[REDACTED]' : (string) $value;
}
return $headers;
}
private static function shouldRedactHeader(string $name): bool
{
$lower = strtolower($name);
foreach (self::REDACTED_HEADER_PATTERNS as $pattern) {
if (str_contains($lower, $pattern)) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
private static function redactParams(array $params): array
{
$result = [];
foreach ($params as $key => $value) {
$result[$key] = self::shouldRedactParam((string) $key) ? '[REDACTED]' : $value;
}
return $result;
}
private static function shouldRedactParam(string $key): bool
{
$lower = strtolower($key);
foreach (self::REDACTED_KEY_PATTERNS as $pattern) {
if (str_contains($lower, $pattern)) {
return true;
}
}
return false;
}
/**
* @return array<string, string>
*/
private static function describeSessionKeys(array $session): array
{
$result = [];
foreach ($session as $key => $value) {
$result[(string) $key] = get_debug_type($value);
}
return $result;
}
/**
* @return array<string, mixed>
*/
private static function collectEnvironment(): array
{
$data = [
'php_version' => PHP_VERSION,
'php_sapi' => PHP_SAPI,
'extensions' => get_loaded_extensions(),
'memory_current' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'env' => self::collectSafeEnv(),
'modules' => [],
];
// Active modules — may fail if container is unavailable.
try {
if (function_exists('app')) {
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
$modules = $registry->getModules();
$data['modules'] = array_map(
fn (object $m): string => $m->id ?? (string) $m,
$modules,
);
}
} catch (\Throwable) {
}
return $data;
}
/**
* @return array<string, string>
*/
private static function collectSafeEnv(): array
{
$result = [];
foreach (self::SAFE_ENV_KEYS as $key) {
$value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
if ($value !== null) {
$result[$key] = (string) $value;
}
}
return $result;
}
/**
* @return array<string, mixed>
*/
private static function collectQueries(): array
{
try {
if (!class_exists(Debugger::class, false) || !Debugger::$enabled) {
return ['available' => false, 'queries' => [], 'start' => null];
}
$queries = Debugger::get('queries');
$start = Debugger::get('start');
if (!is_array($queries)) {
$queries = [];
}
return [
'available' => true,
'queries' => $queries,
'start' => is_float($start) ? $start : null,
'count' => count($queries),
];
} catch (\Throwable) {
return ['available' => false, 'queries' => [], 'start' => null];
}
}
}

243
core/Http/ErrorHandler.php Normal file
View File

@@ -0,0 +1,243 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Debugger;
final class ErrorHandler
{
/** @phpstan-ignore property.onlyWritten (Memory reserve freed on OOM to allow error rendering.) */
private static ?string $reservedMemory = null;
private static bool $registered = false;
private static bool $handling = false;
public static function register(): void
{
if (self::$registered) {
return;
}
self::$registered = true;
self::$reservedMemory = str_repeat('x', 64 * 1024); // 64 KB reserve for OOM recovery.
set_exception_handler([self::class, 'handleException']);
set_error_handler([self::class, 'handleError']);
register_shutdown_function([self::class, 'handleShutdown']);
}
public static function handleException(\Throwable $exception): void
{
// Prevent recursive handling if the error page itself throws.
if (self::$handling) {
self::renderMinimalFallback($exception);
return;
}
self::$handling = true;
try {
// Free reserved memory so we have room to work after OOM.
self::$reservedMemory = null;
// Discard any partial output from the failed request.
if (!self::isPhpUnitRuntime()) {
while (ob_get_level() > 0) {
ob_end_clean();
}
}
// Set HTTP 500 if no error status has been set yet.
$currentStatus = http_response_code();
if ($currentStatus === false || $currentStatus < 400) {
http_response_code(500);
}
// Collect diagnostic data.
$data = ErrorDataCollector::collect($exception);
// Log to file (always, in both dev and prod).
ErrorLogger::log($exception, $data);
// Bail out if headers were already sent (cannot render a page).
if (headers_sent()) {
self::$handling = false;
return;
}
// API requests get a JSON response, not HTML.
if (self::isApiRequest()) {
self::sendApiError($data['request']['request_id'] ?? null);
self::$handling = false;
return;
}
// Render HTML error page.
if (self::shouldShowDebugPage()) {
echo ErrorRenderer::renderDev($data);
} else {
$requestId = (string) ($data['request']['request_id'] ?? 'unknown');
echo ErrorRenderer::renderProd($requestId);
}
} catch (\Throwable $inner) {
self::renderMinimalFallback($exception);
}
self::$handling = false;
}
/**
* Converts PHP errors into ErrorException.
* Respects the error_reporting() bitmask (honors @ suppression operator).
*/
public static function handleError(int $severity, string $message, string $file, int $line): true
{
if (!(error_reporting() & $severity)) {
// Error was suppressed with @.
return true;
}
if (self::shouldSuppressVendorIssue($severity, $file)) {
return true;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
}
/**
* Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.).
*/
public static function handleShutdown(): void
{
self::$reservedMemory = null;
$error = error_get_last();
if (!is_array($error)) {
return;
}
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if (!in_array($error['type'], $fatalTypes, true)) {
return;
}
// Only act if we haven't already handled this (e.g. via set_exception_handler).
if (self::$handling) {
return;
}
$exception = new \ErrorException(
$error['message'],
0,
$error['type'],
$error['file'],
$error['line'],
);
self::handleException($exception);
}
private static function isApiRequest(): bool
{
if (defined('MINTY_API_REQUEST')) {
return true;
}
$uri = $_SERVER['REQUEST_URI'] ?? '';
$path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/');
return str_starts_with($path, 'api/');
}
private static function shouldShowDebugPage(): bool
{
// Check the Debugger flag, which mirrors APP_DEBUG.
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
return true;
}
return false;
}
private static function sendApiError(?string $requestId): void
{
header('Content-Type: application/json; charset=utf-8');
$payload = [
'ok' => false,
'error_code' => 'internal_error',
];
if ($requestId !== null) {
$payload['request_id'] = $requestId;
}
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
}
private static function renderMinimalFallback(\Throwable $exception): void
{
// Last-resort plain-text response when everything else is broken.
$requestId = 'unknown';
try {
$requestId = RequestContext::currentId() ?? RequestContext::id();
} catch (\Throwable) {
}
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
}
echo "500 Internal Server Error\nRequest ID: {$requestId}\n";
// In debug mode, also show the exception for developers.
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
}
}
private static function isPhpUnitRuntime(): bool
{
return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__');
}
private static function shouldSuppressVendorIssue(int $severity, string $file): bool
{
if (!self::isVendorPath($file)) {
return false;
}
return match (self::vendorIssueMode()) {
'suppress_deprecations' => self::isDeprecationSeverity($severity),
'suppress_deprecations_warnings' => self::isDeprecationSeverity($severity) || self::isWarningSeverity($severity),
default => false,
};
}
private static function vendorIssueMode(): string
{
$raw = getenv('APP_VENDOR_PHP_ISSUES_MODE');
if ($raw === false) {
return 'strict';
}
$mode = strtolower(trim((string) $raw));
return in_array($mode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
? $mode
: 'strict';
}
private static function isDeprecationSeverity(int $severity): bool
{
return in_array($severity, [E_DEPRECATED, E_USER_DEPRECATED], true);
}
private static function isWarningSeverity(int $severity): bool
{
return in_array($severity, [E_WARNING, E_USER_WARNING], true);
}
private static function isVendorPath(string $file): bool
{
$normalized = str_replace('\\', '/', $file);
return str_contains($normalized, '/vendor/');
}
}

169
core/Http/ErrorLogger.php Normal file
View File

@@ -0,0 +1,169 @@
<?php
namespace MintyPHP\Http;
final class ErrorLogger
{
private const LOG_DIR = 'storage/logs/errors';
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private const MAX_ROTATED_FILES = 5;
private const REDACTION_MARKER = '[REDACTED]';
public static function log(\Throwable $exception, array $collectedData): void
{
try {
$logDir = self::ensureLogDirectory();
if ($logDir === null) {
return;
}
$logFile = $logDir . '/error-' . date('Y-m-d') . '.log';
self::rotateIfNeeded($logFile);
$entry = self::buildLogEntry($exception, $collectedData);
$json = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
$json = json_encode(['error' => 'Failed to encode log entry', 'request_id' => $entry['request_id'] ?? 'unknown']);
}
file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX);
} catch (\Throwable) {
// Logging must never cause secondary failures.
}
}
private static function ensureLogDirectory(): ?string
{
$dir = self::LOG_DIR;
if (is_dir($dir)) {
return $dir;
}
if (!@mkdir($dir, 0o775, true) && !is_dir($dir)) {
return null;
}
return $dir;
}
private static function rotateIfNeeded(string $filePath): void
{
if (!is_file($filePath)) {
return;
}
$size = @filesize($filePath);
if ($size === false || $size < self::MAX_FILE_SIZE) {
return;
}
// Find the next available rotation slot.
for ($i = self::MAX_ROTATED_FILES; $i >= 2; $i--) {
$older = $filePath . '.' . $i;
$newer = $filePath . '.' . ($i - 1);
if (is_file($newer)) {
@rename($newer, $older);
}
}
@rename($filePath, $filePath . '.1');
}
/**
* @param array{exception?: array, request?: array, environment?: array, queries?: array} $collectedData
* @return array<string, mixed>
*/
private static function buildLogEntry(\Throwable $exception, array $collectedData): array
{
$exceptionData = $collectedData['exception'] ?? [];
$requestData = $collectedData['request'] ?? [];
$envData = $collectedData['environment'] ?? [];
$exceptionMessage = (string) ($exceptionData['message'] ?? $exception->getMessage());
$traceSummary = '';
$frames = $exceptionData['trace'] ?? [];
foreach (array_slice($frames, 0, 10) as $i => $frame) {
$file = $frame['file'] ?? '?';
$line = $frame['line'] ?? '?';
$traceSummary .= "#{$i} {$file}:{$line} ";
}
return [
'timestamp' => date('c'),
'level' => 'error',
'request_id' => $requestData['request_id'] ?? null,
'channel' => $requestData['channel'] ?? 'web',
'exception' => [
'class' => $exceptionData['class'] ?? get_class($exception),
'message' => self::REDACTION_MARKER,
'message_hash' => self::hashMessage($exceptionMessage),
'code' => $exceptionData['code'] ?? $exception->getCode(),
'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()),
'line' => $exceptionData['line'] ?? $exception->getLine(),
],
'trace_summary' => trim($traceSummary),
'request' => [
'method' => $requestData['method'] ?? null,
'path' => self::normalizeRequestPath($requestData['url'] ?? null),
'ip_hash' => self::hashIp($requestData['ip'] ?? null),
'user_id' => $requestData['user_id'] ?? null,
'tenant_id' => $requestData['tenant_id'] ?? null,
],
'php_version' => $envData['php_version'] ?? PHP_VERSION,
'memory_peak_mb' => round(($envData['memory_peak'] ?? memory_get_peak_usage(true)) / 1024 / 1024, 1),
];
}
private static function hashIp(?string $ip): ?string
{
if ($ip === null || $ip === '') {
return null;
}
return 'sha256:' . hash('sha256', $ip);
}
private static function hashMessage(string $message): ?string
{
$message = trim($message);
if ($message === '') {
return null;
}
return 'sha256:' . hash('sha256', $message);
}
private static function normalizeRequestPath(mixed $rawUrl): ?string
{
if (!is_string($rawUrl) || trim($rawUrl) === '') {
return null;
}
$path = parse_url($rawUrl, PHP_URL_PATH);
if (!is_string($path) || trim($path) === '') {
return '/';
}
$path = preg_replace('/[[:cntrl:]]+/', '', $path) ?? '';
$path = trim($path);
if ($path === '') {
return '/';
}
if (!str_starts_with($path, '/')) {
$path = '/' . $path;
}
return substr($path, 0, 500);
}
private static function shortenPath(string $path): string
{
$root = getcwd();
if ($root !== false && str_starts_with($path, $root . '/')) {
return substr($path, strlen($root) + 1);
}
return $path;
}
}

501
core/Http/ErrorRenderer.php Normal file
View File

@@ -0,0 +1,501 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\I18n;
final class ErrorRenderer
{
/**
* Renders the full developer debug page with all four panels.
*
* @param array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>} $data
*/
public static function renderDev(array $data): string
{
$exc = $data['exception'];
$req = $data['request'];
$env = $data['environment'];
$qry = $data['queries'];
$requestId = self::esc((string) ($req['request_id'] ?? 'unknown'));
$statusCode = http_response_code() ?: 500;
$statusText = self::statusText($statusCode);
$queryCount = $qry['count'] ?? count($qry['queries'] ?? []);
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
$excClass = self::esc($exc['class'] ?? self::tr('Exception'));
$excMessage = self::esc((string) ($exc['message'] ?? self::tr('An error occurred')));
$excLocation = self::esc(self::tr(
'in %s on line %s',
self::shortenPath((string) ($exc['file'] ?? '')),
(string) ($exc['line'] ?? '?')
));
$exceptionPanel = self::renderExceptionPanel($exc);
$requestPanel = self::renderRequestPanel($req);
$environmentPanel = self::renderEnvironmentPanel($env);
$queryPanel = self::renderQueryPanel($qry);
$lang = self::esc(self::htmlLang());
$statusTextEsc = self::esc($statusText);
$requestIdLabel = self::esc(self::tr('Request ID'));
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
$tabsLabel = self::esc(self::tr('Error details'));
$tabException = self::esc(self::tr('Exception'));
$tabRequest = self::esc(self::tr('Request'));
$tabEnvironment = self::esc(self::tr('Environment'));
$tabQueries = self::esc(self::tr('Queries'));
return <<<HTML
<!DOCTYPE html>
<html lang="{$lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} {$statusTextEsc}</title>
<style>{$css}</style>
</head>
<body class="app-error-debug">
<header class="app-error-debug-header">
<div class="app-error-debug-container app-error-debug-header__inner">
<div class="app-error-debug-header__status">
<span class="app-error-debug-header__code">{$statusCode}</span>
<span class="app-error-debug-header__label">{$statusTextEsc}</span>
</div>
<div class="app-error-debug-request-id">
{$requestIdLabel}: {$requestId}
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>
</button>
</div>
</div>
</header>
<main class="app-error-debug-container">
<div class="app-error-debug-summary">
<div class="app-error-debug-summary__class">{$excClass}</div>
<div class="app-error-debug-summary__message">{$excMessage}</div>
<div class="app-error-debug-summary__location">{$excLocation}</div>
</div>
<nav class="app-error-debug-tabs" role="tablist" aria-label="{$tabsLabel}">
<button type="button" id="tab-exception" class="app-error-debug-tab active" role="tab" aria-selected="true" aria-controls="panel-exception" data-panel="panel-exception" tabindex="0">{$tabException}</button>
<button type="button" id="tab-request" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-request" data-panel="panel-request" tabindex="-1">{$tabRequest}</button>
<button type="button" id="tab-environment" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-environment" data-panel="panel-environment" tabindex="-1">{$tabEnvironment}</button>
<button type="button" id="tab-queries" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-queries" data-panel="panel-queries" tabindex="-1">{$tabQueries} <span class="app-error-debug-tab__count">{$queryCount}</span></button>
</nav>
<div id="panel-exception" class="app-error-debug-panel active" role="tabpanel" aria-labelledby="tab-exception">{$exceptionPanel}</div>
<div id="panel-request" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-request">{$requestPanel}</div>
<div id="panel-environment" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-environment">{$environmentPanel}</div>
<div id="panel-queries" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-queries">{$queryPanel}</div>
</main>
<script>{$js}</script>
</body>
</html>
HTML;
}
/**
* Renders a clean production error page with request ID only.
* Uses the same design tokens as the dev page for visual consistency.
*/
public static function renderProd(string $requestId): string
{
$statusCode = http_response_code() ?: 500;
$requestId = self::esc($requestId);
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
$lang = self::esc(self::htmlLang());
$errorText = self::esc(self::tr('Error'));
$requestIdLabel = self::esc(self::tr('Request ID'));
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
$message = self::esc(self::tr('An unexpected error occurred. If the problem persists, please contact support with the reference below.'));
$backLabel = self::esc(self::tr('Back to start'));
return <<<HTML
<!DOCTYPE html>
<html lang="{$lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} - {$errorText}</title>
<style>
{$css}
.app-error-prod {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100dvh; padding: calc(var(--app-spacing) * 2); text-align: center;
font-family: var(--app-font-family); background: var(--app-background-color); color: var(--app-color);
}
.app-error-prod__code {
font-size: var(--text-5xl); font-weight: var(--font-bold);
line-height: var(--leading-none); color: var(--app-muted-color);
}
.app-error-prod__message {
margin-top: var(--app-spacing); font-size: var(--text-lg);
color: var(--app-color); max-width: 28rem;
}
.app-error-prod__back {
display: inline-block; margin-top: calc(var(--app-spacing) * 2);
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 1.25);
font-size: var(--text-sm); text-decoration: none;
border: 1px solid var(--app-border); border-radius: var(--app-border-radius);
color: var(--app-color); background: var(--app-card-background-color);
}
.app-error-prod__back:hover { opacity: 0.8; }
</style>
</head>
<body>
<div class="app-error-prod">
<div class="app-error-prod__code">{$statusCode}</div>
<div class="app-error-prod__message">{$message}</div>
<div class="app-error-debug-request-id" style="margin-top: calc(var(--app-spacing) * 1.5);">
{$requestIdLabel}: {$requestId}
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
</div>
<a class="app-error-prod__back" href="/">{$backLabel}</a>
</div>
<script>{$js}</script>
</body>
</html>
HTML;
}
private static function renderExceptionPanel(array $exceptionData): string
{
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc(self::tr('Stack Trace')) . '</div>';
$html .= '<ol class="app-error-debug-trace">';
$frames = $exceptionData['trace'] ?? [];
foreach ($frames as $i => $frame) {
$file = self::esc(self::shortenPath((string) ($frame['file'] ?? '[internal]')));
$line = self::esc((string) ($frame['line'] ?? '?'));
$function = '';
if (isset($frame['class'])) {
$function = self::esc((string) $frame['class'] . '::' . (string) ($frame['function'] ?? '?'));
} elseif (isset($frame['function'])) {
$function = self::esc((string) $frame['function']);
}
$codeHtml = '';
$snippet = $frame['code_snippet'] ?? [];
if (!empty($snippet)) {
$highlightLine = (int) ($frame['line'] ?? 0);
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
}
$codeId = 'frame-code-' . $i;
$toggleId = 'frame-toggle-' . $i;
$isOpen = ($i === 0);
$expanded = $isOpen ? 'true' : 'false';
$openClass = $isOpen ? ' open' : '';
$html .= <<<FRAME
<li class="app-error-debug-frame{$openClass}">
<button type="button" id="{$toggleId}" class="app-error-debug-frame__header app-error-debug-frame__toggle" aria-expanded="{$expanded}" aria-controls="{$codeId}">
<span class="app-error-debug-frame__index">#{$i}</span>
<span class="app-error-debug-frame__file">{$file}</span>:<span class="app-error-debug-frame__line">{$line}</span>
<span class="app-error-debug-frame__function">{$function}</span>
</button>
<div id="{$codeId}" class="app-error-debug-frame__code" role="region" aria-labelledby="{$toggleId}">{$codeHtml}</div>
</li>
FRAME;
}
$html .= '</ol></div>';
// Chained exceptions.
if (isset($exceptionData['previous'])) {
$prev = $exceptionData['previous'];
$html .= '<div class="app-error-debug-chain">';
$html .= '<div class="app-error-debug-chain__label">' . self::esc(self::tr('Caused by')) . '</div>';
$html .= '<div class="app-error-debug-summary__class">' . self::esc((string) ($prev['class'] ?? self::tr('Exception'))) . '</div>';
$html .= '<div class="app-error-debug-summary__message">' . self::esc((string) ($prev['message'] ?? '')) . '</div>';
$html .= '<div class="app-error-debug-summary__location">' . self::esc(self::tr(
'in %s on line %s',
self::shortenPath((string) ($prev['file'] ?? '')),
(string) ($prev['line'] ?? '?')
)) . '</div>';
$html .= '</div>';
}
return $html;
}
/**
* @param array<int, string> $lines
*/
private static function renderCodeBlock(array $lines, int $highlightLine): string
{
$html = '<table class="app-error-debug-code-table">';
foreach ($lines as $lineNo => $code) {
$isHighlight = ($lineNo === $highlightLine) ? ' highlight' : '';
$lineNoEsc = self::esc((string) $lineNo);
$codeEsc = self::esc($code);
$html .= "<tr class=\"{$isHighlight}\"><td class=\"app-error-debug-code-table__line-no\">{$lineNoEsc}</td><td class=\"app-error-debug-code-table__code\">{$codeEsc}</td></tr>";
}
$html .= '</table>';
return $html;
}
private static function renderRequestPanel(array $requestData): string
{
$html = '';
$html .= self::renderInfoSection(self::tr('Request'), [
self::tr('Method') => $requestData['method'] ?? 'GET',
self::tr('URL') => $requestData['url'] ?? '',
self::tr('IP') => $requestData['ip'] ?? '',
self::tr('User Agent') => $requestData['user_agent'] ?? '',
self::tr('Channel') => $requestData['channel'] ?? 'web',
]);
$headers = $requestData['headers'] ?? [];
if (!empty($headers)) {
$html .= self::renderInfoSection(self::tr('Headers'), $headers);
}
$get = $requestData['get'] ?? [];
if (!empty($get)) {
$html .= self::renderInfoSection(self::tr('GET Parameters'), array_map(
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
$get,
));
}
$post = $requestData['post'] ?? [];
if (!empty($post)) {
$html .= self::renderInfoSection(self::tr('POST Parameters'), array_map(
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
$post,
));
}
$session = $requestData['session_keys'] ?? [];
if (!empty($session)) {
$html .= self::renderInfoSection(self::tr('Session (keys + types)'), $session);
}
$html .= self::renderInfoSection(self::tr('Context'), array_filter([
self::tr('User ID') => $requestData['user_id'] ?? null,
self::tr('Tenant ID') => $requestData['tenant_id'] ?? null,
], fn ($v): bool => $v !== null));
return $html;
}
private static function renderEnvironmentPanel(array $envData): string
{
$html = '';
$html .= self::renderInfoSection(self::tr('PHP'), [
self::tr('Version') => $envData['php_version'] ?? PHP_VERSION,
self::tr('SAPI') => $envData['php_sapi'] ?? PHP_SAPI,
self::tr('Memory (current)') => self::formatBytes((int) ($envData['memory_current'] ?? 0)),
self::tr('Memory (peak)') => self::formatBytes((int) ($envData['memory_peak'] ?? 0)),
]);
$env = $envData['env'] ?? [];
if (!empty($env)) {
$html .= self::renderInfoSection(self::tr('Application'), $env);
}
$modules = $envData['modules'] ?? [];
if (!empty($modules)) {
$html .= self::renderInfoSection(self::tr('Active Modules'), array_combine(
array_map(fn ($m): string => (string) $m, $modules),
array_fill(0, count($modules), self::tr('Active')),
) ?: []);
}
$extensions = $envData['extensions'] ?? [];
if (!empty($extensions)) {
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
. self::esc(self::tr('Loaded Extensions (%d)', count($extensions)))
. '</div>';
$html .= '<div style="font-family: var(--app-font-family-monospace); font-size: var(--text-xs); color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.5); background: var(--app-card-background-color); border-radius: var(--app-border-radius); border: 1px solid var(--app-border);">';
$html .= self::esc(implode(', ', $extensions));
$html .= '</div></div>';
}
return $html;
}
private static function renderQueryPanel(array $queryData): string
{
if (!($queryData['available'] ?? false)) {
return '<div class="app-error-debug-empty">' . self::esc(self::tr('Query tracking is not available (Debugger disabled or not initialized).')) . '</div>';
}
$queries = $queryData['queries'] ?? [];
if (empty($queries)) {
return '<div class="app-error-debug-empty">' . self::esc(self::tr('No queries were executed before the error occurred.')) . '</div>';
}
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
. self::esc(self::tr('Queries (%d)', count($queries)))
. '</div>';
foreach ($queries as $i => $query) {
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
$duration = '';
if (is_array($query) && isset($query[1])) {
$durationMs = round((float) $query[1] * 1000, 2);
$duration = $durationMs . ' ms';
}
$querySqlId = 'query-sql-' . $i;
$queryToggleId = 'query-toggle-' . $i;
$html .= '<div class="app-error-debug-query">';
$html .= '<button type="button" id="' . self::esc($queryToggleId) . '" class="app-error-debug-query__header app-error-debug-query__toggle" aria-expanded="false" aria-controls="' . self::esc($querySqlId) . '">';
$html .= '<span>' . self::esc(self::truncate($sql, 120)) . '</span>';
if ($duration !== '') {
$html .= '<span class="app-error-debug-query__duration">' . self::esc($duration) . '</span>';
}
$html .= '</button>';
$html .= '<div id="' . self::esc($querySqlId) . '" class="app-error-debug-query__sql" role="region" aria-labelledby="' . self::esc($queryToggleId) . '">' . self::esc($sql) . '</div>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
/**
* @param array<string, mixed> $items
*/
private static function renderInfoSection(string $title, array $items): string
{
if (empty($items)) {
return '';
}
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc($title) . '</div>';
$html .= '<table class="app-error-debug-info-table">';
foreach ($items as $key => $value) {
$keyEsc = self::esc((string) $key);
$valueStr = (string) $value;
$isRedacted = ($valueStr === '[REDACTED]');
$valueEsc = $isRedacted
? '<span class="app-error-debug-redacted">[REDACTED]</span>'
: self::esc($valueStr);
$html .= "<tr><th>{$keyEsc}</th><td>{$valueEsc}</td></tr>";
}
$html .= '</table></div>';
return $html;
}
private static function loadAsset(string $path): string
{
$resolved = realpath($path);
if ($resolved === false || !is_file($resolved)) {
return '/* asset not found: ' . basename($path) . ' */';
}
return (string) file_get_contents($resolved);
}
public static function esc(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
private static function shortenPath(string $path): string
{
$root = getcwd();
if ($root !== false && str_starts_with($path, $root . '/')) {
return substr($path, strlen($root) + 1);
}
return $path;
}
private static function statusText(int $code): string
{
return match ($code) {
400 => self::tr('Bad Request'),
401 => self::tr('Unauthorized'),
403 => self::tr('Forbidden'),
404 => self::tr('Not Found'),
405 => self::tr('Method Not Allowed'),
408 => self::tr('Request Timeout'),
422 => self::tr('Unprocessable Entity'),
429 => self::tr('Too Many Requests'),
500 => self::tr('Internal Server Error'),
502 => self::tr('Bad Gateway'),
503 => self::tr('Service Unavailable'),
default => self::tr('Error'),
};
}
private static function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1024 * 1024) {
return round($bytes / 1024, 1) . ' KB';
}
return round($bytes / (1024 * 1024), 1) . ' MB';
}
private static function truncate(string $text, int $length): string
{
if (mb_strlen($text) <= $length) {
return $text;
}
return mb_substr($text, 0, $length) . '...';
}
private static function tr(string $text, mixed ...$args): string
{
if (function_exists('t')) {
try {
/** @var string $translated */
$translated = t($text, ...$args);
return $translated;
} catch (\Throwable) {
// Rendering must stay resilient, even if translation bootstrap failed.
}
}
if ($args === []) {
return $text;
}
return (string) @vsprintf($text, $args);
}
private static function htmlLang(): string
{
$locale = trim((string) (I18n::$locale ?? I18n::$defaultLocale ?? 'en'));
if ($locale === '') {
return 'en';
}
$locale = strtolower(str_replace('_', '-', $locale));
if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $locale) === 1) {
return $locale;
}
if (preg_match('/^[a-z]{2,3}/', $locale, $matches) === 1) {
return $matches[0];
}
return 'en';
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace MintyPHP\Http\Input;
final class FormErrors
{
/** @var array<string, array<int, string>> */
private array $errors = [];
public function add(string $field, string $message): self
{
$field = trim($field);
if ($field === '') {
$field = 'input';
}
$message = trim($message);
if ($message === '') {
return $this;
}
$this->errors[$field] ??= [];
$this->errors[$field][] = $message;
return $this;
}
/**
* @param array<int, string> $messages
*/
public function addMany(string $field, array $messages): self
{
foreach ($messages as $message) {
$this->add($field, (string) $message);
}
return $this;
}
public function addGlobal(string $message): self
{
return $this->add('input', $message);
}
/**
* @param array<string, array<int, string>|string>|array<int, string>|self $errors
*/
public function merge(array|self $errors): self
{
if ($errors instanceof self) {
$errors = $errors->toArray();
}
if (array_is_list($errors)) {
foreach ($errors as $message) {
$this->addGlobal((string) $message);
}
return $this;
}
foreach ($errors as $field => $messages) {
if (is_array($messages)) {
$this->addMany((string) $field, array_values(array_map('strval', $messages)));
continue;
}
$this->add((string) $field, (string) $messages);
}
return $this;
}
public function hasAny(): bool
{
return $this->errors !== [];
}
/**
* @return array<string, array<int, string>>
*/
public function toArray(): array
{
return $this->errors;
}
/**
* @return array<int, string>
*/
public function toFlatList(): array
{
$flat = [];
foreach ($this->errors as $messages) {
foreach ($messages as $message) {
$flat[] = $message;
}
}
return $flat;
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace MintyPHP\Http\Input;
use MintyPHP\Http\Request;
final class RequestInput
{
/**
* @param array<string, mixed> $query
* @param array<string, mixed> $body
* @param array<string, mixed> $files
*/
public function __construct(
private readonly string $method,
private readonly array $query,
private readonly array $body,
private readonly array $files
) {
}
public function method(): string
{
return $this->method;
}
public function isMethod(string ...$methods): bool
{
foreach ($methods as $method) {
if ($this->method === strtoupper(trim($method))) {
return true;
}
}
return false;
}
/**
* @return array<string, mixed>
*/
public function queryAll(): array
{
return $this->query;
}
public function query(string $key, mixed $default = null): mixed
{
return array_key_exists($key, $this->query) ? $this->query[$key] : $default;
}
public function hasQuery(string $key): bool
{
return array_key_exists($key, $this->query);
}
public function queryString(string $key, string $default = ''): string
{
return trim((string) $this->query($key, $default));
}
public function queryInt(string $key, int $default = 0): int
{
return (int) $this->query($key, $default);
}
/**
* @return array<int|string, mixed>
*/
public function queryArray(string $key): array
{
$value = $this->query($key, []);
return is_array($value) ? $value : [];
}
/**
* @return array<string, mixed>
*/
public function bodyAll(): array
{
return $this->body;
}
public function body(string $key, mixed $default = null): mixed
{
return array_key_exists($key, $this->body) ? $this->body[$key] : $default;
}
public function hasBody(string $key): bool
{
return array_key_exists($key, $this->body);
}
public function bodyString(string $key, string $default = ''): string
{
return trim((string) $this->body($key, $default));
}
public function bodyInt(string $key, int $default = 0): int
{
return (int) $this->body($key, $default);
}
/**
* @return array<int|string, mixed>
*/
public function bodyArray(string $key): array
{
$value = $this->body($key, []);
return is_array($value) ? $value : [];
}
/**
* @return array<string, mixed>
*/
public function filesAll(): array
{
return $this->files;
}
/**
* @return mixed
*/
public function file(string $key, mixed $default = null): mixed
{
return array_key_exists($key, $this->files) ? $this->files[$key] : $default;
}
public function hasFile(string $key): bool
{
return array_key_exists($key, $this->files);
}
public function wantsJson(): bool
{
return Request::wantsJson();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace MintyPHP\Http\Input;
final class RequestInputFactory
{
/** @var array<string, mixed>|null */
private ?array $cachedApiBody = null;
public function create(): RequestInput
{
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
if ($method === '') {
$method = 'GET';
}
$query = $_GET;
$files = $_FILES;
// API requests carry a JSON body; web requests use $_POST form data.
if (defined('MINTY_API_REQUEST')) {
return new RequestInput($method, $query, $this->readApiBody(), $files);
}
$body = $_POST;
return new RequestInput($method, $query, $body, $files);
}
/**
* @return array<string, mixed>
*/
private function readApiBody(): array
{
if ($this->cachedApiBody !== null) {
return $this->cachedApiBody;
}
$raw = file_get_contents('php://input');
if (!is_string($raw) || $raw === '') {
$this->cachedApiBody = [];
return $this->cachedApiBody;
}
$decoded = json_decode($raw, true);
$this->cachedApiBody = is_array($decoded) ? $decoded : [];
return $this->cachedApiBody;
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace MintyPHP\Http;
/**
* Stores and retrieves the URL the user intended to visit before
* being redirected to the login page (session timeout, auth guard).
*
* URLs are validated to prevent open-redirect attacks.
*/
class IntendedUrlService
{
private const SESSION_KEY = 'intended_url';
private const MAX_LENGTH = 2048;
private const FALLBACK = 'admin';
/** Prefixes that must never be restored (would cause redirect loops or security issues). */
private const BLOCKED_PREFIXES = [
'auth/',
'api/',
'login',
'logout',
'flash/',
'branding/',
];
/**
* Store the intended URL in the session.
*/
public function store(string $url, SessionStoreInterface $session): void
{
$sanitized = $this->sanitize($url);
if ($sanitized !== '') {
$session->set(self::SESSION_KEY, $sanitized);
}
}
/**
* Retrieve the intended URL without removing it.
*/
public function retrieve(SessionStoreInterface $session): ?string
{
$value = $session->get(self::SESSION_KEY);
return is_string($value) && $value !== '' ? $value : null;
}
/**
* Retrieve the intended URL and remove it from the session.
* Returns the fallback ('admin') if no valid URL is stored.
*
* The stored URL is a full REQUEST_URI (e.g. /de/admin/users?page=2)
* but Router::redirect() expects a route path without leading slash
* and without locale prefix (e.g. admin/users?page=2), because it
* prepends getBaseUrl() which already includes the base path.
*/
public function retrieveAndForget(SessionStoreInterface $session): string
{
$value = $this->retrieve($session);
$session->remove(self::SESSION_KEY);
if ($value === null) {
return self::FALLBACK;
}
return $this->toRoutePath($value);
}
/**
* Build a login redirect URL with the return_to query parameter.
*/
public function buildLoginRedirect(string $loginPath, string $returnTo): string
{
$sanitized = $this->sanitize($returnTo);
if ($sanitized === '') {
return $loginPath;
}
$separator = str_contains($loginPath, '?') ? '&' : '?';
return $loginPath . $separator . 'return_to=' . rawurlencode($sanitized);
}
/**
* Sanitize and validate a URL for safe redirect.
* Returns empty string if the URL is not safe.
*/
public function sanitize(string $url): string
{
$url = trim($url);
if ($url === '' || mb_strlen($url) > self::MAX_LENGTH) {
return '';
}
// Must be a relative path (starts with /)
if (!str_starts_with($url, '/')) {
return '';
}
// Must not contain protocol indicators (prevent //evil.com or javascript:)
if (str_contains($url, '://') || str_starts_with($url, '//')) {
return '';
}
// Strip the path to check against blocked prefixes.
// URL might be /en/admin/users or /admin/users — strip locale prefix.
$path = $this->extractPathWithoutLocale($url);
foreach (self::BLOCKED_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix) || $path === rtrim($prefix, '/')) {
return '';
}
}
return $url;
}
/**
* Convert a full REQUEST_URI into a Router::redirect()-compatible route path.
*
* /de/admin/users?page=2 → admin/users?page=2
* /admin/users → admin/users
*/
public function toRoutePath(string $url): string
{
// Remove leading slash
$path = ltrim($url, '/');
// Remove locale prefix (2-letter code followed by /)
if (preg_match('#^[a-z]{2}/#', $path)) {
$path = substr($path, 3);
}
return $path !== '' ? $path : self::FALLBACK;
}
/**
* Extract the path portion without query string and without locale prefix.
*/
private function extractPathWithoutLocale(string $url): string
{
// Remove query string and fragment
$path = strtok($url, '?#') ?: $url;
// Remove leading slash
$path = ltrim($path, '/');
// Remove locale prefix (2-letter code followed by /)
if (preg_match('#^[a-z]{2}/#', $path)) {
$path = substr($path, 3);
}
return $path;
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\I18n;
/**
* Handles locale detection and URL manipulation for multi-language support.
*/
class LocaleResolver
{
private array $availableLocales;
private string $defaultLocale;
private string $basePath;
public function __construct(?array $availableLocales = null, ?string $defaultLocale = null)
{
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
$this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->defaultLocale]);
$this->basePath = trim(parse_url(\MintyPHP\Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
}
/**
* Parse a request URI and extract locale information.
*
* @return array{
* locale: string,
* pathWithoutLocale: string,
* query: string,
* hadLocaleInUrl: bool,
* hadInvalidLocale: bool
* }
*/
public function parseUri(string $uri): array
{
$path = parse_url($uri, PHP_URL_PATH) ?: '';
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
$relativePath = Request::stripBasePath($path);
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
$localeCandidate = $segments[0] ?? '';
$hadLocaleInUrl = false;
$hadInvalidLocale = false;
$locale = '';
if ($localeCandidate !== '' && in_array($localeCandidate, $this->availableLocales, true)) {
$locale = array_shift($segments);
$hadLocaleInUrl = true;
} elseif ($localeCandidate !== '' && $this->looksLikeLocale($localeCandidate)) {
array_shift($segments);
$hadInvalidLocale = true;
}
return [
'locale' => $locale,
'pathWithoutLocale' => implode('/', $segments),
'query' => $query,
'hadLocaleInUrl' => $hadLocaleInUrl,
'hadInvalidLocale' => $hadInvalidLocale,
];
}
/**
* Resolve the effective locale from multiple sources (priority order):
* 1. URL segment (explicit)
* 2. User session preference
* 3. Cookie preference
* 4. Default locale
*/
public function resolveLocale(string $urlLocale, ?string $userLocale = null, ?string $cookieLocale = null): string
{
if ($urlLocale !== '' && $this->isValidLocale($urlLocale)) {
return $urlLocale;
}
if ($userLocale !== null && $userLocale !== '' && $this->isValidLocale($userLocale)) {
return $userLocale;
}
if ($cookieLocale !== null && $cookieLocale !== '' && $this->isValidLocale($cookieLocale)) {
return $cookieLocale;
}
return $this->defaultLocale;
}
/**
* Build a new REQUEST_URI without the locale segment.
*/
public function buildUriWithoutLocale(string $pathWithoutLocale, string $query): string
{
$newPath = $this->basePath !== '' ? '/' . $this->basePath : '';
if ($pathWithoutLocale !== '') {
$newPath .= '/' . $pathWithoutLocale;
} elseif ($newPath === '') {
$newPath = '/';
}
return $newPath . ($query !== '' ? '?' . $query : '');
}
/**
* Check if a locale string is valid (exists in available locales).
*/
public function isValidLocale(string $locale): bool
{
return in_array($locale, $this->availableLocales, true);
}
public function getAvailableLocales(): array
{
return $this->availableLocales;
}
public function getDefaultLocale(): string
{
return $this->defaultLocale;
}
private function looksLikeLocale(string $candidate): bool
{
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;
}
}

97
core/Http/Request.php Normal file
View File

@@ -0,0 +1,97 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\I18n;
use MintyPHP\Router;
class Request
{
public static function stripBasePath(string $path): string
{
$base = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
$path = ltrim($path, '/');
if ($base !== '' && strpos($path, $base . '/') === 0) {
return substr($path, strlen($base) + 1);
}
if ($base !== '' && $path === $base) {
return '';
}
return $path;
}
public static function path(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '';
$path = parse_url($uri, PHP_URL_PATH) ?: '';
return self::stripBasePath($path);
}
// Returns a safe redirect target after login — guards against open-redirect attacks
// by rejecting any value with a scheme or host (i.e. an absolute external URL).
public static function safeReturnTarget(string $returnParam = ''): string
{
if ($returnParam !== '') {
$parts = parse_url($returnParam);
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
$path = self::stripBasePath($parts['path'] ?? '');
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
return $path . $query;
}
}
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if ($referer !== '') {
$parts = parse_url($referer);
$host = $parts['host'] ?? '';
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
if ($host === '' || $host === $currentHost) {
$path = self::stripBasePath($parts['path'] ?? '');
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
return $path . $query;
}
}
return '';
}
public static function pathWithQuery(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '';
$path = self::path();
$query = parse_url($uri, PHP_URL_QUERY);
return $path . ($query ? '?' . $query : '');
}
public static function stripLocale(string $path, ?array $locales = null): string
{
$locales = $locales ?? (defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]);
$path = ltrim($path, '/');
if ($path === '') {
return '';
}
$parts = explode('/', $path);
while ($parts && $parts[0] !== '' && in_array($parts[0], $locales, true)) {
array_shift($parts);
}
return implode('/', $parts);
}
public static function withLocale(string $path = '', ?string $locale = null): string
{
$locale = $locale ?? (I18n::$locale ?? I18n::$defaultLocale);
$path = ltrim($path, '/');
return ($locale !== '' ? $locale . '/' : '') . $path;
}
public static function wantsJson(): bool
{
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
return stripos($accept, 'application/json') !== false || $requestedWith !== '';
}
}

View File

@@ -0,0 +1,233 @@
<?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);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MintyPHP\Http;
class RequestRuntime implements RequestRuntimeInterface
{
public function method(): string
{
$method = strtoupper(trim((string) ($this->context()['method'] ?? '')));
if ($method === '') {
return 'GET';
}
return substr($method, 0, 8);
}
public function path(): string
{
return trim((string) ($this->context()['path'] ?? ''));
}
public function ip(): string
{
return trim((string) ($this->context()['ip'] ?? ''));
}
public function userAgent(): string
{
return trim((string) ($this->context()['user_agent'] ?? ''));
}
/**
* @return array<string, mixed>
*/
public function queryParams(): array
{
/** @var array<string, mixed> $query */
$query = $_GET;
return $query;
}
public function isSecure(): bool
{
$https = strtolower(trim((string) ($_SERVER['HTTPS'] ?? '')));
if (in_array($https, ['on', '1', 'true'], true)) {
return true;
}
$forwarded = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
if ($forwarded === '') {
return false;
}
$parts = explode(',', $forwarded);
return trim((string) ($parts[0] ?? '')) === 'https';
}
/**
* @return array<string, mixed>
*/
private function context(): array
{
RequestContext::ensureStarted();
return RequestContext::context();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace MintyPHP\Http;
interface RequestRuntimeInterface
{
public function method(): string;
public function path(): string;
public function ip(): string;
public function userAgent(): string;
/**
* @return array<string, mixed>
*/
public function queryParams(): array;
public function isSecure(): bool;
}

100
core/Http/RouteCatalog.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
namespace MintyPHP\Http;
use RuntimeException;
/**
* Loads and validates core route declarations from config/routes.php.
*/
final class RouteCatalog
{
/** @var list<array{path: string, target: string, public: bool}> */
private array $coreRoutes;
/** @var list<string> */
private array $corePublicPaths;
/**
* @param list<array{path: string, target: string, public: bool}> $coreRoutes
* @param list<string> $corePublicPaths
*/
private function __construct(array $coreRoutes, array $corePublicPaths)
{
$this->coreRoutes = $coreRoutes;
$this->corePublicPaths = $corePublicPaths;
}
public static function fromConfigFile(string $routesFile): self
{
if (!is_file($routesFile)) {
throw new RuntimeException("Core route config not found: {$routesFile}");
}
$loaded = include $routesFile;
if (!is_array($loaded)) {
throw new RuntimeException("Core route config '{$routesFile}' must return an array.");
}
return self::fromArray($loaded, $routesFile);
}
/**
* @param array<mixed> $routes
*/
public static function fromArray(array $routes, string $source = 'config/routes.php'): self
{
$normalized = [];
$publicPaths = [];
$seenPaths = [];
foreach (array_values($routes) as $index => $route) {
if (!is_array($route)) {
throw new RuntimeException("Invalid route definition at {$source}[{$index}]: expected array.");
}
$path = trim((string) ($route['path'] ?? ''));
$target = trim((string) ($route['target'] ?? ''));
if ($path === '' || $target === '') {
throw new RuntimeException("Invalid route definition at {$source}[{$index}]: non-empty path and target are required.");
}
if (isset($seenPaths[$path])) {
throw new RuntimeException("Core route path conflict: '{$path}' is defined multiple times in {$source}.");
}
$seenPaths[$path] = true;
$isPublic = (bool) ($route['public'] ?? false);
$normalized[] = [
'path' => $path,
'target' => $target,
'public' => $isPublic,
];
if ($isPublic) {
$publicPaths[] = $path;
}
}
$publicPaths = array_values(array_unique($publicPaths));
sort($publicPaths);
return new self($normalized, $publicPaths);
}
/**
* @return list<array{path: string, target: string, public: bool}>
*/
public function coreRoutes(): array
{
return $this->coreRoutes;
}
/**
* @return list<string>
*/
public function corePublicPaths(): array
{
return $this->corePublicPaths;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Router;
use RuntimeException;
/**
* Registers core + module routes into the runtime router.
*/
final class RouteRegistrar
{
public function __construct(private readonly ModuleRegistry $moduleRegistry)
{
}
public function register(RouteCatalog $catalog): void
{
$coreRoutePaths = [];
foreach ($catalog->coreRoutes() as $route) {
$path = trim($route['path']);
$target = trim($route['target']);
if ($path === '' || $target === '') {
continue;
}
$coreRoutePaths[$path] = $target;
Router::addRoute($path, $target);
}
$modulePaths = [];
foreach ($this->moduleRegistry->getRoutes() as $route) {
$path = trim($route['path']);
$target = trim($route['target']);
$moduleId = trim($route['module_id']);
if ($path === '' || $target === '') {
continue;
}
if (isset($coreRoutePaths[$path])) {
throw new RuntimeException(
"Module route path conflict: '{$path}' from module '{$moduleId}' collides with core route target '{$coreRoutePaths[$path]}'."
);
}
if (isset($modulePaths[$path])) {
throw new RuntimeException(
"Module route path conflict: '{$path}' from module '{$moduleId}' collides with module '{$modulePaths[$path]}'."
);
}
$modulePaths[$path] = $moduleId;
Router::addRoute($path, $target);
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace MintyPHP\Http;
class SessionStore implements SessionStoreInterface
{
public function get(string $key, mixed $default = null): mixed
{
$session = $this->all();
if (!array_key_exists($key, $session)) {
return $default;
}
return $session[$key];
}
public function set(string $key, mixed $value): void
{
if (!is_array($_SESSION ?? null)) {
$_SESSION = [];
}
$_SESSION[$key] = $value;
}
public function has(string $key): bool
{
$session = $this->all();
return array_key_exists($key, $session);
}
public function remove(string $key): void
{
if (!is_array($_SESSION ?? null)) {
$_SESSION = [];
}
unset($_SESSION[$key]);
}
/**
* @return array<string, mixed>
*/
public function all(): array
{
/** @var array<string, mixed> $session */
$session = $_SESSION;
return $session;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace MintyPHP\Http;
interface SessionStoreInterface
{
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value): void;
public function has(string $key): bool;
public function remove(string $key): void;
/**
* @return array<string, mixed>
*/
public function all(): array;
}