go-learn/server/lib/http.php
sab.code.lab 1c85091186 chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:34:02 +02:00

113 lines
3.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}