chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1c85091186
184 changed files with 33303 additions and 0 deletions
113
server/lib/http.php
Normal file
113
server/lib/http.php
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue