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
99
server/lib/auth.php
Normal file
99
server/lib/auth.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Сессии и текущий пользователь.
|
||||
* PHP native-сессии, cookie HttpOnly, SameSite=Lax, Secure при HTTPS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Старт сессии с параметрами cookie по контракту.
|
||||
* Идемпотентно (повторный вызов безопасен).
|
||||
*/
|
||||
function session_start_secure(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
// Fallback save_path (пустой/несуществующий/недоступный — типично для
|
||||
// ручных сборок и части shared-хостингов): уходим в sys_get_temp_dir().
|
||||
$savePath = (string)ini_get('session.save_path');
|
||||
if (
|
||||
$savePath === ''
|
||||
|| !is_dir($savePath)
|
||||
|| !is_writable($savePath)
|
||||
) {
|
||||
ini_set('session.save_path', sys_get_temp_dir());
|
||||
}
|
||||
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_set_cookie_params([
|
||||
'path' => '/',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => $https,
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий пользователь по сессии или null.
|
||||
*
|
||||
* @return array{id: int, email: string}|null
|
||||
*/
|
||||
function current_user(PDO $pdo): ?array
|
||||
{
|
||||
session_start_secure();
|
||||
$id = $_SESSION['user_id'] ?? null;
|
||||
if (!is_int($id)) {
|
||||
return null;
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$user = $stmt->fetch();
|
||||
if (!is_array($user)) {
|
||||
return null;
|
||||
}
|
||||
return ['id' => (int)$user['id'], 'email' => (string)$user['email']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий пользователь или 401.
|
||||
*
|
||||
* @return array{id: int, email: string}
|
||||
*/
|
||||
function require_user(PDO $pdo): array
|
||||
{
|
||||
$user = current_user($pdo);
|
||||
if ($user === null) {
|
||||
error_out(401, 'unauthorized');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** Установить сессию после успешного login (id перегенерируется). */
|
||||
function login_user(int $id): void
|
||||
{
|
||||
session_start_secure();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $id;
|
||||
}
|
||||
|
||||
/** Полный выход: очистка $_SESSION, cookie и уничтожение сессии. */
|
||||
function logout_user(): void
|
||||
{
|
||||
session_start_secure();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', [
|
||||
'expires' => time() - 42000,
|
||||
'path' => $params['path'],
|
||||
'domain' => $params['domain'],
|
||||
'secure' => $params['secure'],
|
||||
'httponly' => $params['httponly'],
|
||||
'samesite' => $params['samesite'] ?? 'Lax',
|
||||
]);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
95
server/lib/db.php
Normal file
95
server/lib/db.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Подключение к SQLite через PDO и идемпотентная init-миграция схемы.
|
||||
* Контракт: docs/INTERFACES.md, раздел «Личный кабинет (этап 8)».
|
||||
*/
|
||||
|
||||
/**
|
||||
* Путь к БД: server/config.php, при отсутствии — config.example.php
|
||||
* (который читает env GOLEARN_DB — так работает smoke-тест).
|
||||
*
|
||||
* @return array{db_path: string}
|
||||
*/
|
||||
function golearn_config(): array
|
||||
{
|
||||
$file = __DIR__ . '/../config.php';
|
||||
if (!is_file($file)) {
|
||||
$file = __DIR__ . '/../config.example.php';
|
||||
}
|
||||
$config = require $file;
|
||||
if (!is_array($config) || empty($config['db_path']) || !is_string($config['db_path'])) {
|
||||
throw new RuntimeException('config: нет db_path');
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ленивый синглтон PDO. PRAGMA по контракту: WAL, busy_timeout=5000,
|
||||
* foreign_keys=ON. Ошибки — только исключения (текст наружу не отдаём).
|
||||
*/
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo instanceof PDO) {
|
||||
return $pdo;
|
||||
}
|
||||
$config = golearn_config();
|
||||
$pdo = new PDO('sqlite:' . $config['db_path'], null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||
golearn_migrate($pdo);
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Идемпотентная init-миграция схемы (IF NOT EXISTS).
|
||||
*/
|
||||
function golearn_migrate(PDO $pdo): void
|
||||
{
|
||||
$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT UNIQUE COLLATE NOCASE,
|
||||
pass_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)'
|
||||
);
|
||||
$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS progress (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id),
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)'
|
||||
);
|
||||
$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS rate_log (
|
||||
ip TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
)'
|
||||
);
|
||||
$pdo->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_rate_log_ip_action_ts
|
||||
ON rate_log (ip, action, ts)'
|
||||
);
|
||||
// Этап 8.1: токены восстановления пароля (аддитивно, IF NOT EXISTS).
|
||||
$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)'
|
||||
);
|
||||
$pdo->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_password_resets_token_hash
|
||||
ON password_resets (token_hash)'
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
54
server/lib/ratelimit.php
Normal file
54
server/lib/ratelimit.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Rate limiting по IP через таблицу rate_log.
|
||||
* Контракт: register/login — 10 попыток за 10 минут на IP → 429
|
||||
* {"error":"rate_limited"}; проверка ДО проверки пароля; успешный login
|
||||
* чистит счётчик ip+action. Prepared statements везде.
|
||||
* Этап 8.1: reset_request — 5 за 10 минут, reset_confirm — 10 за 10 минут.
|
||||
*/
|
||||
|
||||
const GOLEARN_RATE_MAX = 10;
|
||||
const GOLEARN_RATE_WINDOW = 600; // 10 минут, секунд
|
||||
|
||||
const GOLEARN_RATE_MAX_RESET_REQUEST = 5;
|
||||
const GOLEARN_RATE_MAX_RESET_CONFIRM = 10;
|
||||
|
||||
/**
|
||||
* 429, если попыток ip+action за окно уже >= лимита.
|
||||
* Вызывается ДО записи текущей попытки и до проверки пароля.
|
||||
*/
|
||||
function rate_check(
|
||||
PDO $pdo,
|
||||
string $ip,
|
||||
string $action,
|
||||
int $max = GOLEARN_RATE_MAX
|
||||
): void {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) AS n FROM rate_log
|
||||
WHERE ip = ? AND action = ? AND ts > ?'
|
||||
);
|
||||
$stmt->execute([$ip, $action, time() - GOLEARN_RATE_WINDOW]);
|
||||
$row = $stmt->fetch();
|
||||
if (is_array($row) && (int)$row['n'] >= $max) {
|
||||
error_out(429, 'rate_limited');
|
||||
}
|
||||
}
|
||||
|
||||
/** Записать попытку (после прохождения rate_check). */
|
||||
function rate_record(PDO $pdo, string $ip, string $action): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO rate_log (ip, action, ts) VALUES (?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$ip, $action, time()]);
|
||||
}
|
||||
|
||||
/** Сбросить счётчик ip+action (успешный login). */
|
||||
function rate_clear(PDO $pdo, string $ip, string $action): void
|
||||
{
|
||||
$stmt = $pdo->prepare('DELETE FROM rate_log WHERE ip = ? AND action = ?');
|
||||
$stmt->execute([$ip, $action]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue