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:
100
core/Http/AccessControl.php
Normal file
100
core/Http/AccessControl.php
Normal 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
286
core/Http/ApiAuth.php
Normal 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
323
core/Http/ApiBootstrap.php
Normal 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
297
core/Http/ApiResponse.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
56
core/Http/AssetDetector.php
Normal file
56
core/Http/AssetDetector.php
Normal 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
34
core/Http/CookieStore.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
18
core/Http/CookieStoreInterface.php
Normal file
18
core/Http/CookieStoreInterface.php
Normal 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;
|
||||
}
|
||||
349
core/Http/ErrorDataCollector.php
Normal file
349
core/Http/ErrorDataCollector.php
Normal 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
243
core/Http/ErrorHandler.php
Normal 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
169
core/Http/ErrorLogger.php
Normal 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
501
core/Http/ErrorRenderer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
101
core/Http/Input/FormErrors.php
Normal file
101
core/Http/Input/FormErrors.php
Normal 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;
|
||||
}
|
||||
}
|
||||
137
core/Http/Input/RequestInput.php
Normal file
137
core/Http/Input/RequestInput.php
Normal 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();
|
||||
}
|
||||
}
|
||||
48
core/Http/Input/RequestInputFactory.php
Normal file
48
core/Http/Input/RequestInputFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
153
core/Http/IntendedUrlService.php
Normal file
153
core/Http/IntendedUrlService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
126
core/Http/LocaleResolver.php
Normal file
126
core/Http/LocaleResolver.php
Normal 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
97
core/Http/Request.php
Normal 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 !== '';
|
||||
}
|
||||
}
|
||||
233
core/Http/RequestContext.php
Normal file
233
core/Http/RequestContext.php
Normal 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);
|
||||
}
|
||||
}
|
||||
66
core/Http/RequestRuntime.php
Normal file
66
core/Http/RequestRuntime.php
Normal 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();
|
||||
}
|
||||
}
|
||||
21
core/Http/RequestRuntimeInterface.php
Normal file
21
core/Http/RequestRuntimeInterface.php
Normal 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
100
core/Http/RouteCatalog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
core/Http/RouteRegistrar.php
Normal file
56
core/Http/RouteRegistrar.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
core/Http/SessionStore.php
Normal file
50
core/Http/SessionStore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
19
core/Http/SessionStoreInterface.php
Normal file
19
core/Http/SessionStoreInterface.php
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user