Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
3.4 KiB
PHP
113 lines
3.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* HTTP-примитивы API: security-заголовки, JSON-ответы, чтение тела,
|
||
* проверка метода и CSRF-заголовка X-GoLearn-Client.
|
||
* Контракт: docs/INTERFACES.md, раздел «Личный кабинет (этап 8)».
|
||
*/
|
||
|
||
/** Лимит тела запроса по контракту: 256 КБ, сверх — 413. */
|
||
const GOLEARN_MAX_BODY = 262144;
|
||
|
||
// Этот файл подключается первым в каждом эндпоинте: гасим вывод ошибок
|
||
// наружу (контракт: без текста исключений в ответах), ошибки — в лог.
|
||
ini_set('display_errors', '0');
|
||
ini_set('log_errors', '1');
|
||
|
||
/** Security-заголовки на все ответы API. Вызывать до любого вывода. */
|
||
function http_security_headers(): void
|
||
{
|
||
header('X-Content-Type-Options: nosniff');
|
||
header('Cache-Control: no-store');
|
||
}
|
||
|
||
/**
|
||
* JSON-ответ и завершение запроса.
|
||
*
|
||
* @param array<string, mixed> $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;
|
||
}
|