$data */ function json_out(array $data, int $status = 200): never { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } /** * Ошибка JSON-ом без текста исключений: {"error": "<код>"}. */ function error_out(int $status, string $code): never { json_out(['error' => $code], $status); } /** 204 No Content (прогресса нет). */ function no_content_out(): never { http_response_code(204); exit; } /** Метод запроса обязан совпадать, иначе 405. */ function method_must(string ...$methods): void { $method = $_SERVER['REQUEST_METHOD'] ?? ''; if (!in_array($method, $methods, true)) { header('Allow: ' . implode(', ', $methods)); error_out(405, 'method_not_allowed'); } } /** * Мутирующие эндпоинты требуют X-GoLearn-Client: web (CSRF-мера вместе * с SameSite=Lax), иначе 403. */ function require_client_header(): void { $value = $_SERVER['HTTP_X_GOLEARN_CLIENT'] ?? ''; if ($value !== 'web') { error_out(403, 'forbidden'); } } /** IP клиента по контракту — REMOTE_ADDR. */ function client_ip(): string { return (string)($_SERVER['REMOTE_ADDR'] ?? ''); } /** * Сырое тело запроса с лимитом 256 КБ (413 сверх лимита). * Проверяем и Content-Length, и фактический размер. */ function read_body(): string { $length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); if ($length > GOLEARN_MAX_BODY) { error_out(413, 'payload_too_large'); } $body = file_get_contents('php://input'); if ($body === false) { $body = ''; } if (strlen($body) > GOLEARN_MAX_BODY) { error_out(413, 'payload_too_large'); } return $body; } /** * JSON-тело как объект (без assoc, чтобы отличать объект от массива). * Невалидный JSON / не-объект верхнего уровня — 422. */ function read_json_object(): object { $body = json_decode(read_body()); if (!is_object($body)) { error_out(422, 'invalid_json'); } return $body; }