major update
This commit is contained in:
@@ -2,13 +2,32 @@
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Repository\Access\RolePermissionRepository;
|
||||
use MintyPHP\Repository\Access\UserRoleRepository;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Service\Auth\ApiTokenService;
|
||||
use MintyPHP\Service\Auth\AuthScopeGateway;
|
||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
class ApiAuth
|
||||
{
|
||||
/** @var (callable(): AuthScopeGateway)|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;
|
||||
@@ -16,6 +35,22 @@ class ApiAuth
|
||||
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.
|
||||
*/
|
||||
@@ -49,19 +84,17 @@ class ApiAuth
|
||||
return false;
|
||||
}
|
||||
|
||||
$authServicesFactory = new AuthServicesFactory();
|
||||
$scopeGateway = $authServicesFactory->createAuthScopeGateway();
|
||||
$result = $authServicesFactory->createApiTokenService()->validate($bearerToken);
|
||||
$scopeGateway = self::scopeGateway();
|
||||
$result = self::apiTokenService()->validate($bearerToken);
|
||||
if ($result === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $result['user'];
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
$userServicesFactory = new UserServicesFactory();
|
||||
$userRoleRepository = $userServicesFactory->createUserRoleRepository();
|
||||
$rolePermissionRepository = (new AccessServicesFactory())->createRolePermissionRepository();
|
||||
$userTenantContextService = $userServicesFactory->createUserTenantContextService();
|
||||
$userRoleRepository = self::userRoleRepository();
|
||||
$rolePermissionRepository = self::rolePermissionRepository();
|
||||
$userTenantContextService = self::userTenantContextService();
|
||||
|
||||
// Load permissions directly (bypass session cache)
|
||||
$roleIds = $userRoleRepository->listRoleIdsByUserId($userId);
|
||||
@@ -110,9 +143,14 @@ class ApiAuth
|
||||
return self::$currentPermissions ?? [];
|
||||
}
|
||||
|
||||
public static function hasPermission(string $key): bool
|
||||
public static function can(string $ability, array $context = []): bool
|
||||
{
|
||||
return in_array($key, self::$currentPermissions ?? [], true);
|
||||
$decision = self::authorizationService()->authorize($ability, [
|
||||
'actor_user_id' => self::userId(),
|
||||
'scoped_tenant_id' => self::scopedTenantId(),
|
||||
...$context,
|
||||
]);
|
||||
return $decision->isAllowed();
|
||||
}
|
||||
|
||||
public static function tenantId(): ?int
|
||||
@@ -146,7 +184,7 @@ class ApiAuth
|
||||
*/
|
||||
public static function requireResourceAccess(string $resource, int $resourceId): void
|
||||
{
|
||||
$scopeGateway = (new AuthServicesFactory())->createAuthScopeGateway();
|
||||
$scopeGateway = self::scopeGateway();
|
||||
if (!$scopeGateway->canAccess($resource, $resourceId, self::userId())) {
|
||||
ApiResponse::notFound();
|
||||
}
|
||||
@@ -164,7 +202,7 @@ class ApiAuth
|
||||
ApiResponse::forbidden();
|
||||
}
|
||||
|
||||
$scopeGateway = (new AuthServicesFactory())->createAuthScopeGateway();
|
||||
$scopeGateway = self::scopeGateway();
|
||||
if (!$scopeGateway->resourceBelongsToTenant($resource, $resourceId, $tenantId)) {
|
||||
ApiResponse::notFound();
|
||||
}
|
||||
@@ -175,8 +213,14 @@ class ApiAuth
|
||||
*/
|
||||
public static function canSelfManageTokens(): bool
|
||||
{
|
||||
return self::hasPermission(\MintyPHP\Service\Access\PermissionService::USERS_SELF_UPDATE)
|
||||
|| self::hasPermission(\MintyPHP\Service\Access\PermissionService::API_TOKENS_MANAGE);
|
||||
$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
|
||||
@@ -185,4 +229,49 @@ class ApiAuth
|
||||
ApiResponse::forbidden('api_tokens_self_manage_forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
private static function scopeGateway(): AuthScopeGateway
|
||||
{
|
||||
return self::resolveDependency(self::$authScopeGatewayResolver, AuthScopeGateway::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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Service\Audit\ApiAuditService;
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Security\SecurityServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
|
||||
class ApiBootstrap
|
||||
{
|
||||
@@ -18,10 +18,26 @@ class ApiBootstrap
|
||||
|
||||
private static bool $initialized = false;
|
||||
private static bool $shutdownRegistered = false;
|
||||
private static ?SecurityServicesFactory $securityServicesFactory = null;
|
||||
private static ?RateLimiterService $rateLimiterService = null;
|
||||
private static ?SettingServicesFactory $settingServicesFactory = null;
|
||||
private static ?SettingGateway $settingGateway = null;
|
||||
/** @var (callable(): ApiAuditService)|null */
|
||||
private static $apiAuditServiceResolver = null;
|
||||
/** @var (callable(): SettingGateway)|null */
|
||||
private static $settingGatewayResolver = null;
|
||||
/** @var (callable(): RateLimiterService)|null */
|
||||
private static $rateLimiterServiceResolver = null;
|
||||
/** @var (callable(): ApiSystemAuditReporter)|null */
|
||||
private static $apiSystemAuditReporterResolver = null;
|
||||
|
||||
public static function configure(
|
||||
callable $apiAuditServiceResolver,
|
||||
callable $settingGatewayResolver,
|
||||
callable $rateLimiterServiceResolver,
|
||||
callable $apiSystemAuditReporterResolver
|
||||
): void {
|
||||
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
|
||||
self::$settingGatewayResolver = $settingGatewayResolver;
|
||||
self::$rateLimiterServiceResolver = $rateLimiterServiceResolver;
|
||||
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API request context.
|
||||
@@ -40,8 +56,16 @@ class ApiBootstrap
|
||||
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();
|
||||
\auditServicesFactory()->createApiAuditService()->startRequestContext();
|
||||
self::apiAuditService()->startRequestContext();
|
||||
self::startSystemAuditReporter();
|
||||
self::setCorsHeaders();
|
||||
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||
@@ -96,7 +120,8 @@ class ApiBootstrap
|
||||
if ($statusCode <= 0) {
|
||||
$statusCode = 200;
|
||||
}
|
||||
\auditServicesFactory()->createApiAuditService()->finish($statusCode);
|
||||
self::apiAuditService()->finish($statusCode);
|
||||
self::finishSystemAuditReporter($statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,7 +132,8 @@ class ApiBootstrap
|
||||
if ($statusCode <= 0) {
|
||||
$statusCode = 200;
|
||||
}
|
||||
\auditServicesFactory()->createApiAuditService()->finish($statusCode);
|
||||
self::apiAuditService()->finish($statusCode);
|
||||
self::finishSystemAuditReporter($statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,7 +141,8 @@ class ApiBootstrap
|
||||
if ($statusCode < 400) {
|
||||
$statusCode = 500;
|
||||
}
|
||||
\auditServicesFactory()->createApiAuditService()->finish($statusCode, 'fatal_error');
|
||||
self::apiAuditService()->finish($statusCode, 'fatal_error');
|
||||
self::finishSystemAuditReporter($statusCode, 'fatal_error');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,29 +235,53 @@ class ApiBootstrap
|
||||
|
||||
private static function settings(): SettingGateway
|
||||
{
|
||||
if (self::$settingGateway instanceof SettingGateway) {
|
||||
return self::$settingGateway;
|
||||
}
|
||||
|
||||
if (!(self::$settingServicesFactory instanceof SettingServicesFactory)) {
|
||||
self::$settingServicesFactory = new SettingServicesFactory();
|
||||
}
|
||||
|
||||
self::$settingGateway = self::$settingServicesFactory->createSettingGateway();
|
||||
return self::$settingGateway;
|
||||
return self::resolveDependency(self::$settingGatewayResolver, SettingGateway::class, 'ApiBootstrap');
|
||||
}
|
||||
|
||||
private static function rateLimiter(): RateLimiterService
|
||||
{
|
||||
if (self::$rateLimiterService instanceof RateLimiterService) {
|
||||
return self::$rateLimiterService;
|
||||
return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap');
|
||||
}
|
||||
|
||||
private static function apiAuditService(): ApiAuditService
|
||||
{
|
||||
return self::resolveDependency(self::$apiAuditServiceResolver, ApiAuditService::class, 'ApiBootstrap');
|
||||
}
|
||||
|
||||
private static function systemAuditReporter(): ApiSystemAuditReporter
|
||||
{
|
||||
return self::resolveDependency(self::$apiSystemAuditReporterResolver, ApiSystemAuditReporter::class, 'ApiBootstrap');
|
||||
}
|
||||
|
||||
private static function startSystemAuditReporter(): void
|
||||
{
|
||||
try {
|
||||
self::systemAuditReporter()->start();
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
self::systemAuditReporter()->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);
|
||||
}
|
||||
|
||||
if (!(self::$securityServicesFactory instanceof SecurityServicesFactory)) {
|
||||
self::$securityServicesFactory = new SecurityServicesFactory();
|
||||
$service = $resolver();
|
||||
if (!$service instanceof $expectedClass) {
|
||||
throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass);
|
||||
}
|
||||
|
||||
self::$rateLimiterService = self::$securityServicesFactory->createRateLimiterService();
|
||||
return self::$rateLimiterService;
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,31 @@
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Http\Input\FormErrors;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Service\Audit\ApiAuditService;
|
||||
|
||||
class ApiResponse
|
||||
{
|
||||
/** @var (callable(): ApiAuditService)|null */
|
||||
private static $apiAuditServiceResolver = null;
|
||||
/** @var (callable(): AuthorizationService)|null */
|
||||
private static $authorizationServiceResolver = null;
|
||||
/** @var (callable(): ApiSystemAuditReporter)|null */
|
||||
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);
|
||||
@@ -21,7 +44,7 @@ class ApiResponse
|
||||
|
||||
public static function error(string $error, int $status = 400, array $extra = []): never
|
||||
{
|
||||
$body = array_merge(['error' => $error], $extra);
|
||||
$body = self::buildErrorBody($error, $extra);
|
||||
self::send($body, $status, $error);
|
||||
}
|
||||
|
||||
@@ -50,10 +73,16 @@ class ApiResponse
|
||||
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(
|
||||
['error' => 'rate_limit_exceeded'],
|
||||
self::buildErrorBody('rate_limit_exceeded', ['retry_after' => $retryAfter]),
|
||||
429,
|
||||
'rate_limit_exceeded',
|
||||
['Retry-After: ' . $retryAfter]
|
||||
@@ -118,39 +147,147 @@ class ApiResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific permission key.
|
||||
*/
|
||||
public static function requirePermission(string $key): void
|
||||
public static function requireAbility(string $ability, array $context = []): void
|
||||
{
|
||||
self::requireAuth();
|
||||
if (!ApiAuth::hasPermission($key)) {
|
||||
$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) {
|
||||
\auditServicesFactory()->createApiAuditService()->finish($status, $errorCode);
|
||||
self::finishSystemAuditReporter($status, $errorCode);
|
||||
self::apiAuditService()->finish($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';
|
||||
$json = json_encode(['error' => 'serialization_error'], JSON_UNESCAPED_UNICODE) ?: '{"error":"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');
|
||||
\auditServicesFactory()->createApiAuditService()->finish($status, $errorCode);
|
||||
self::finishSystemAuditReporter($status, $errorCode);
|
||||
self::apiAuditService()->finish($status, $errorCode);
|
||||
die($json);
|
||||
}
|
||||
|
||||
private static function requestId(): string
|
||||
{
|
||||
$requestId = RequestContext::currentId();
|
||||
if (is_string($requestId) && trim($requestId) !== '') {
|
||||
return trim($requestId);
|
||||
}
|
||||
|
||||
$requestId = self::apiAuditService()->currentRequestId();
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
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 apiAuditService(): ApiAuditService
|
||||
{
|
||||
if (!is_callable(self::$apiAuditServiceResolver)) {
|
||||
throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiAuditService::class);
|
||||
}
|
||||
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (!$service instanceof ApiAuditService) {
|
||||
throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiAuditService::class);
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
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 systemAuditReporter(): ApiSystemAuditReporter
|
||||
{
|
||||
if (!is_callable(self::$apiSystemAuditReporterResolver)) {
|
||||
throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiSystemAuditReporter::class);
|
||||
}
|
||||
|
||||
$service = (self::$apiSystemAuditReporterResolver)();
|
||||
if (!$service instanceof ApiSystemAuditReporter) {
|
||||
throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiSystemAuditReporter::class);
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
self::systemAuditReporter()->finish($statusCode, $errorCode);
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
211
lib/Http/ApiSystemAuditReporter.php
Normal file
211
lib/Http/ApiSystemAuditReporter.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
|
||||
class ApiSystemAuditReporter
|
||||
{
|
||||
/**
|
||||
* @var array{
|
||||
* started_at: float,
|
||||
* method: string,
|
||||
* path: string,
|
||||
* finished: bool
|
||||
* }|null
|
||||
*/
|
||||
private ?array $context = null;
|
||||
|
||||
public function __construct(private readonly SystemAuditService $systemAuditService)
|
||||
{
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
if ($this->context !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
RequestContext::ensureStarted();
|
||||
$requestContext = RequestContext::context();
|
||||
|
||||
$method = strtoupper(trim((string) ($requestContext['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? 'GET'))));
|
||||
if ($method === '') {
|
||||
$method = 'GET';
|
||||
}
|
||||
|
||||
$path = trim((string) ($requestContext['path'] ?? ''));
|
||||
if ($path === '') {
|
||||
$fallbackPath = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
||||
$path = is_string($fallbackPath) ? trim($fallbackPath) : '';
|
||||
}
|
||||
|
||||
$this->context = [
|
||||
'started_at' => microtime(true),
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'finished' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function finish(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
if (!is_array($this->context) || !empty($this->context['finished'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->context['finished'] = true;
|
||||
|
||||
$method = strtoupper(trim((string) $this->context['method']));
|
||||
$path = trim((string) $this->context['path']);
|
||||
$statusCode = $this->normalizeStatusCode($statusCode);
|
||||
|
||||
if (!$this->shouldRecord($method, $path, $statusCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$durationMs = max(0, (int) round((microtime(true) - (float) $this->context['started_at']) * 1000));
|
||||
$securityEndpoint = $this->isSecurityEndpoint($path);
|
||||
$writeMethod = $this->isWriteMethod($method);
|
||||
$normalizedErrorCode = trim((string) ($errorCode ?? ''));
|
||||
|
||||
$metadata = [
|
||||
'endpoint_key' => $this->endpointKey($path),
|
||||
'status_code' => $statusCode,
|
||||
'status_class' => $this->statusClass($statusCode),
|
||||
'duration_ms' => $durationMs,
|
||||
'auth_mode' => $this->authMode(),
|
||||
'write_method' => $writeMethod,
|
||||
'security_endpoint' => $securityEndpoint,
|
||||
];
|
||||
|
||||
$this->systemAuditService->record(
|
||||
'api.request',
|
||||
$this->outcomeFromStatusCode($statusCode),
|
||||
[
|
||||
'actor_user_id' => ApiAuth::isAuthenticated() ? ApiAuth::userId() : null,
|
||||
'error_code' => $normalizedErrorCode !== '' ? substr($normalizedErrorCode, 0, 100) : null,
|
||||
'metadata' => $metadata,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function shouldRecord(string $method, string $path, int $statusCode): bool
|
||||
{
|
||||
if ($method === 'OPTIONS') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isWriteMethod($method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($statusCode >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isSecurityEndpoint($path) && $statusCode >= 400;
|
||||
}
|
||||
|
||||
private function isWriteMethod(string $method): bool
|
||||
{
|
||||
return in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true);
|
||||
}
|
||||
|
||||
private function isSecurityEndpoint(string $path): bool
|
||||
{
|
||||
$path = '/' . ltrim(strtolower(trim($path)), '/');
|
||||
if ($path === '/api/v1/auth/login' || $path === '/api/v1/me/password') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($path, '/api/v1/me/tokens');
|
||||
}
|
||||
|
||||
private function endpointKey(string $path): string
|
||||
{
|
||||
$path = '/' . ltrim(trim($path), '/');
|
||||
if ($path === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
$segments = array_values(array_filter(explode('/', trim($path, '/')), static fn (string $segment): bool => $segment !== ''));
|
||||
if ($segments === []) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($segments as $segment) {
|
||||
$value = strtolower(trim($segment));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ctype_digit($value)) {
|
||||
$normalized[] = '{id}';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (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) {
|
||||
$normalized[] = '{uuid}';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-f0-9]{24}$/i', $value) === 1) {
|
||||
$normalized[] = '{token_selector}';
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = substr($value, 0, 64);
|
||||
}
|
||||
|
||||
return '/' . implode('/', $normalized);
|
||||
}
|
||||
|
||||
private function outcomeFromStatusCode(int $statusCode): string
|
||||
{
|
||||
if ($statusCode >= 500) {
|
||||
return SystemAuditOutcome::Failed->value;
|
||||
}
|
||||
if ($statusCode >= 400) {
|
||||
return SystemAuditOutcome::Denied->value;
|
||||
}
|
||||
return SystemAuditOutcome::Success->value;
|
||||
}
|
||||
|
||||
private function statusClass(int $statusCode): string
|
||||
{
|
||||
if ($statusCode >= 100 && $statusCode <= 199) {
|
||||
return '1xx';
|
||||
}
|
||||
if ($statusCode >= 200 && $statusCode <= 299) {
|
||||
return '2xx';
|
||||
}
|
||||
if ($statusCode >= 300 && $statusCode <= 399) {
|
||||
return '3xx';
|
||||
}
|
||||
if ($statusCode >= 400 && $statusCode <= 499) {
|
||||
return '4xx';
|
||||
}
|
||||
if ($statusCode >= 500 && $statusCode <= 599) {
|
||||
return '5xx';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function authMode(): string
|
||||
{
|
||||
if (ApiAuth::isAuthenticated() || ApiAuth::extractBearerToken() !== '') {
|
||||
return 'token';
|
||||
}
|
||||
|
||||
return 'public';
|
||||
}
|
||||
|
||||
private function normalizeStatusCode(int $statusCode): int
|
||||
{
|
||||
return ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500;
|
||||
}
|
||||
}
|
||||
101
lib/Http/Input/FormErrors.php
Normal file
101
lib/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
lib/Http/Input/RequestInput.php
Normal file
137
lib/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();
|
||||
}
|
||||
}
|
||||
47
lib/Http/Input/RequestInputFactory.php
Normal file
47
lib/Http/Input/RequestInputFactory.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
226
lib/Http/RequestContext.php
Normal file
226
lib/Http/RequestContext.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 : '';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user