- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks - Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists - Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services - Add Microsoft OIDC SSO, API token management, and user lifecycle features - Add swagger-ui vendor integration and OpenAPI spec - Add production Docker setup and bin/ scripts - Update composer dependencies, config, templates, and frontend assets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.0 KiB
PHP
202 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Http;
|
|
|
|
use MintyPHP\Service\Audit\ApiAuditService;
|
|
use MintyPHP\Service\Security\RateLimiterService;
|
|
use MintyPHP\Service\Settings\SettingService;
|
|
|
|
class ApiBootstrap
|
|
{
|
|
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;
|
|
|
|
/**
|
|
* Initialize the API request context.
|
|
*
|
|
* Call this at the top of every API page action.
|
|
* Sets MINTY_ALLOW_OUTPUT, CORS headers, authenticates via Bearer token.
|
|
*/
|
|
public static function init(): void
|
|
{
|
|
if (self::$initialized) {
|
|
return;
|
|
}
|
|
self::$initialized = true;
|
|
|
|
if (!defined('MINTY_ALLOW_OUTPUT')) {
|
|
define('MINTY_ALLOW_OUTPUT', true);
|
|
}
|
|
|
|
self::registerShutdownHandler();
|
|
ApiAuditService::startRequestContext();
|
|
self::setCorsHeaders();
|
|
|
|
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
|
http_response_code(204);
|
|
die();
|
|
}
|
|
|
|
self::enforceRateLimit();
|
|
|
|
$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(SettingService::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');
|
|
}
|
|
|
|
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;
|
|
}
|
|
ApiAuditService::finish($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;
|
|
}
|
|
ApiAuditService::finish($statusCode);
|
|
return;
|
|
}
|
|
|
|
$statusCode = (int) http_response_code();
|
|
if ($statusCode < 400) {
|
|
$statusCode = 500;
|
|
}
|
|
ApiAuditService::finish($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 = RateLimiterService::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 = RateLimiterService::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)));
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|