chore: восстановление репозитория из снапшота v0.3.1

Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sab.code.lab 2026-08-07 09:34:02 +02:00
commit 1c85091186
184 changed files with 33303 additions and 0 deletions

113
server/lib/http.php Normal file
View file

@ -0,0 +1,113 @@
<?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;
}