Files
breadcrumb-the-shire/lib/Http/ApiBootstrap.php

297 lines
10 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Http;
2026-03-04 15:56:58 +01:00
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Security\RateLimiterService;
2026-03-06 00:44:52 +01:00
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
2026-03-06 00:44:52 +01:00
// 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
{
2026-03-06 00:44:52 +01:00
// 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;
2026-03-04 15:56:58 +01:00
/** @var (callable(): ApiAuditService)|null */
private static $apiAuditServiceResolver = null;
2026-03-06 00:44:52 +01:00
/** @var (callable(): SettingsApiPolicyGateway)|null */
private static $settingsApiPolicyGatewayResolver = null;
2026-03-04 15:56:58 +01:00
/** @var (callable(): RateLimiterService)|null */
private static $rateLimiterServiceResolver = null;
/** @var (callable(): ApiSystemAuditReporter)|null */
private static $apiSystemAuditReporterResolver = null;
public static function configure(
callable $apiAuditServiceResolver,
2026-03-06 00:44:52 +01:00
callable $settingsApiPolicyGatewayResolver,
2026-03-04 15:56:58 +01:00
callable $rateLimiterServiceResolver,
callable $apiSystemAuditReporterResolver
): void {
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
2026-03-06 00:44:52 +01:00
self::$settingsApiPolicyGatewayResolver = $settingsApiPolicyGatewayResolver;
2026-03-04 15:56:58 +01:00
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);
}
2026-03-04 15:56:58 +01:00
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();
2026-03-04 15:56:58 +01:00
self::apiAuditService()->startRequestContext();
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;
}
2026-03-06 00:44:52 +01:00
$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');
}
2026-03-06 00:44:52 +01:00
// 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;
}
2026-03-04 15:56:58 +01:00
self::apiAuditService()->finish($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;
}
2026-03-04 15:56:58 +01:00
self::apiAuditService()->finish($statusCode);
self::finishSystemAuditReporter($statusCode);
return;
}
$statusCode = (int) http_response_code();
if ($statusCode < 400) {
$statusCode = 500;
}
2026-03-04 15:56:58 +01:00
self::apiAuditService()->finish($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';
}
2026-02-23 12:58:19 +01:00
$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;
}
2026-02-23 12:58:19 +01:00
$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)));
}
}
2026-03-06 00:44:52 +01:00
// 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;
}
2026-02-23 12:58:19 +01:00
2026-03-06 00:44:52 +01:00
private static function settingsApiPolicy(): SettingsApiPolicyGateway
2026-02-23 12:58:19 +01:00
{
2026-03-06 00:44:52 +01:00
return self::resolveDependency(
self::$settingsApiPolicyGatewayResolver,
SettingsApiPolicyGateway::class,
'ApiBootstrap'
);
2026-03-04 15:56:58 +01:00
}
private static function rateLimiter(): RateLimiterService
{
return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap');
}
2026-02-23 12:58:19 +01:00
2026-03-04 15:56:58 +01:00
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
2026-02-23 12:58:19 +01:00
}
2026-03-04 15:56:58 +01:00
}
2026-02-23 12:58:19 +01:00
2026-03-04 15:56:58 +01:00
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
{
try {
self::systemAuditReporter()->finish($statusCode, $errorCode);
} catch (\Throwable) {
// fail-open
}
2026-02-23 12:58:19 +01:00
}
2026-03-04 15:56:58 +01:00
private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed
2026-02-23 12:58:19 +01:00
{
2026-03-04 15:56:58 +01:00
if (!is_callable($resolver)) {
throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass);
2026-02-23 12:58:19 +01:00
}
2026-03-04 15:56:58 +01:00
$service = $resolver();
if (!$service instanceof $expectedClass) {
throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass);
2026-02-23 12:58:19 +01:00
}
2026-03-04 15:56:58 +01:00
return $service;
2026-02-23 12:58:19 +01:00
}
}