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
17
server/.htaccess
Normal file
17
server/.htaccess
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Этап 8: серверное API. Apache 2.4 (shared-хостинг).
|
||||
|
||||
# Запрет листинга каталогов.
|
||||
Options -Indexes
|
||||
|
||||
# Security-заголовки на все ответы (дублируют PHP — на случай статики/ошибок).
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Cache-Control "no-store"
|
||||
</IfModule>
|
||||
|
||||
# Конфиг и файлы SQLite наружу не отдаём (V.4).
|
||||
<IfModule mod_authz_core.c>
|
||||
<FilesMatch "(^config\.php$|\.sqlite(-wal|-shm)?$)">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
38
server/README.md
Normal file
38
server/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# server/ — PHP API личного кабинета (этап 8)
|
||||
|
||||
Без фреймворка, синтаксис ≥ PHP 8.0. Контракт: `docs/INTERFACES.md`,
|
||||
раздел «Личный кабинет и синхронизация прогресса (этап 8)», ADR-0004.
|
||||
|
||||
## Структура
|
||||
|
||||
- `api/` — эндпоинты: `register`, `login`, `logout`, `me`, `progress`,
|
||||
`health` (на хостинге — `/api/*.php`).
|
||||
- `lib/` — общий код: `db.php` (PDO + init-миграция), `http.php`
|
||||
(заголовки/JSON/лимит тела), `auth.php` (сессии), `ratelimit.php`.
|
||||
- `config.example.php` — пример конфига. Скопировать в `config.php`
|
||||
(не коммитится, см. корневой `.gitignore`), указать путь к БД
|
||||
**вне web root**. Env `GOLEARN_DB` имеет приоритет (нужно smoke-тесту).
|
||||
- `tests/smoke.sh` — смоук-тест по контракту.
|
||||
|
||||
## Локальный запуск smoke-теста
|
||||
|
||||
Нужны PHP ≥ 8.0 с pdo_sqlite и curl:
|
||||
|
||||
```sh
|
||||
PHP=/path/to/php sh server/tests/smoke.sh
|
||||
```
|
||||
|
||||
(по умолчанию используется `/tmp/php-run`; можно просто
|
||||
`sh server/tests/smoke.sh`, если `/tmp/php-run` существует, или задать
|
||||
`PHP=$(command -v php)`).
|
||||
|
||||
Скрипт поднимает `php -S 127.0.0.1:8099 -t server/` с временной БД в
|
||||
/tmp (через `GOLEARN_DB`), гонит curl-сценарий из контракта
|
||||
(register/login/me/progress/413/429/logout) и удаляет временные файлы.
|
||||
Код выхода ненулевой при любом фейле.
|
||||
|
||||
## Деплой (кратко)
|
||||
|
||||
`server/` → `/api` на хостинге, `config.php` — рядом, файл БД — вне
|
||||
web root, права на запись в каталог БД у PHP. Smoke на хостинге =
|
||||
зелёный `GET /api/health.php`. Подробности — `docs/deploy.md` (V.2).
|
||||
89
server/api/captcha.php
Normal file
89
server/api/captcha.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GET /api/captcha.php → 200 image/svg+xml
|
||||
*
|
||||
* Автономная SVG-капча (без GD и внешних сервисов), контракт:
|
||||
* docs/INTERFACES.md, раздел «Кабинет 2.0 … (этап 8.1)».
|
||||
* 5 символов (алфавит без неоднозначных 0/O, 1/I/l), каждый glyph —
|
||||
* случайный поворот/сдвиг/размер, 3–5 шумовых кривых. В PHP-сессию
|
||||
* пишется sha256(strtolower(code)) + expires (10 минут); проверка —
|
||||
* одноразовая, в register.php.
|
||||
*
|
||||
* Тестовый режим GOLEARN_TEST=1: дополнительно отдаётся заголовок
|
||||
* X-Captcha-Debug с кодом (используется smoke-тестом). В проде env
|
||||
* отсутствует — заголовок не выставляется.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../lib/http.php';
|
||||
require __DIR__ . '/../lib/auth.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('GET');
|
||||
|
||||
// Алфавит без неоднозначных символов (0/O, 1/I/l исключены).
|
||||
$alphabet = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
|
||||
$max = strlen($alphabet) - 1;
|
||||
$code = '';
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$code .= $alphabet[random_int(0, $max)];
|
||||
}
|
||||
|
||||
session_start_secure();
|
||||
$_SESSION['captcha_hash'] = hash('sha256', strtolower($code));
|
||||
$_SESSION['captcha_expires'] = time() + 600; // 10 минут
|
||||
|
||||
// --- SVG: 200x60, glyph'ы + шумовые кривые ---
|
||||
$palette = ['#1a5276', '#7b241c', '#1e8449', '#6c3483', '#9a7d0a'];
|
||||
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"'
|
||||
. ' viewBox="0 0 200 60">';
|
||||
$svg .= '<rect width="200" height="60" fill="#f7f5f0"/>';
|
||||
|
||||
// 3–5 шумовых кривых (кубические Безье через всё поле).
|
||||
$curves = random_int(3, 5);
|
||||
for ($i = 0; $i < $curves; $i++) {
|
||||
$x1 = random_int(-10, 30);
|
||||
$y1 = random_int(0, 60);
|
||||
$x2 = random_int(60, 140);
|
||||
$y2 = random_int(0, 60);
|
||||
$x3 = random_int(170, 210);
|
||||
$y3 = random_int(0, 60);
|
||||
$color = $palette[random_int(0, count($palette) - 1)];
|
||||
$width = random_int(1, 2);
|
||||
$svg .= '<path d="M' . $x1 . ' ' . $y1
|
||||
. ' C' . random_int(20, 80) . ' ' . random_int(0, 60)
|
||||
. ',' . $x2 . ' ' . $y2
|
||||
. ',' . $x3 . ' ' . $y3 . '"'
|
||||
. ' stroke="' . $color . '" stroke-width="' . $width . '"'
|
||||
. ' fill="none" opacity="0.6"/>';
|
||||
}
|
||||
|
||||
// Glyph'ы: случайные поворот, сдвиг и размер каждого символа.
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$x = 22 + $i * 36 + random_int(-4, 4);
|
||||
$y = random_int(34, 44);
|
||||
$angle = random_int(-25, 25);
|
||||
$size = random_int(26, 34);
|
||||
$color = $palette[random_int(0, count($palette) - 1)];
|
||||
$svg .= '<text x="' . $x . '" y="' . $y . '"'
|
||||
. ' font-family="monospace" font-size="' . $size . '"'
|
||||
. ' font-weight="bold" fill="' . $color . '"'
|
||||
. ' transform="rotate(' . $angle . ' ' . $x . ' ' . $y . ')">'
|
||||
. $code[$i] . '</text>';
|
||||
}
|
||||
$svg .= '</svg>';
|
||||
|
||||
header('Content-Type: image/svg+xml');
|
||||
if (getenv('GOLEARN_TEST') === '1') {
|
||||
// Только тестовый режим: отладочная выдача кода smoke-тесту.
|
||||
header('X-Captcha-Debug: ' . $code);
|
||||
}
|
||||
echo $svg;
|
||||
} catch (Throwable $e) {
|
||||
error_log('captcha: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
22
server/api/health.php
Normal file
22
server/api/health.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GET /api/health.php → 200 {"status":"ok"} (V.6).
|
||||
* Проверяет доступность БД; при сбое — 503 без текста исключения.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../lib/http.php';
|
||||
require __DIR__ . '/../lib/db.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('GET');
|
||||
db()->query('SELECT 1');
|
||||
json_out(['status' => 'ok']);
|
||||
} catch (Throwable $e) {
|
||||
error_log('health: ' . $e->getMessage());
|
||||
json_out(['status' => 'error'], 503);
|
||||
}
|
||||
47
server/api/login.php
Normal file
47
server/api/login.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /api/login.php {email, password}
|
||||
* → 200 {ok:true} + сессия | 401 | 403 | 413 | 422 | 429.
|
||||
* Rate limit проверяется ДО проверки пароля; успешный login чистит
|
||||
* счётчик ip+action.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../lib/http.php';
|
||||
require __DIR__ . '/../lib/db.php';
|
||||
require __DIR__ . '/../lib/auth.php';
|
||||
require __DIR__ . '/../lib/ratelimit.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('POST');
|
||||
require_client_header();
|
||||
|
||||
$pdo = db();
|
||||
$ip = client_ip();
|
||||
rate_check($pdo, $ip, 'login');
|
||||
rate_record($pdo, $ip, 'login');
|
||||
|
||||
$body = read_json_object();
|
||||
$email = trim((string)($body->email ?? ''));
|
||||
$password = (string)($body->password ?? '');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, pass_hash FROM users WHERE email = ?');
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!is_array($user) || !password_verify($password, (string)$user['pass_hash'])) {
|
||||
error_out(401, 'unauthorized');
|
||||
}
|
||||
|
||||
rate_clear($pdo, $ip, 'login');
|
||||
login_user((int)$user['id']);
|
||||
|
||||
json_out(['ok' => true]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('login: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
24
server/api/logout.php
Normal file
24
server/api/logout.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /api/logout.php → 200 {ok:true}, сессия уничтожена | 403.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../lib/http.php';
|
||||
require __DIR__ . '/../lib/auth.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('POST');
|
||||
require_client_header();
|
||||
|
||||
logout_user();
|
||||
|
||||
json_out(['ok' => true]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('logout: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
24
server/api/me.php
Normal file
24
server/api/me.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GET /api/me.php → 200 {email} | 401.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../lib/http.php';
|
||||
require __DIR__ . '/../lib/db.php';
|
||||
require __DIR__ . '/../lib/auth.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('GET');
|
||||
|
||||
$user = require_user(db());
|
||||
|
||||
json_out(['email' => $user['email']]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('me: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
68
server/api/password-reset/confirm.php
Normal file
68
server/api/password-reset/confirm.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /api/password-reset/confirm.php {token, password}
|
||||
* → 200 {ok:true} (пароль обновлён, токен удалён, просроченные токены
|
||||
* очищены) | 400 {error:"token_invalid"} | 403 | 413 | 422 (пароль < 8)
|
||||
* | 429.
|
||||
*
|
||||
* Rate limit: 10 за 10 минут на IP.
|
||||
* Контракт: docs/INTERFACES.md, раздел «Кабинет 2.0 … (этап 8.1)».
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../../lib/http.php';
|
||||
require __DIR__ . '/../../lib/db.php';
|
||||
require __DIR__ . '/../../lib/ratelimit.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('POST');
|
||||
|
||||
$pdo = db();
|
||||
$ip = client_ip();
|
||||
rate_check($pdo, $ip, 'reset_confirm', GOLEARN_RATE_MAX_RESET_CONFIRM);
|
||||
require_client_header();
|
||||
rate_record($pdo, $ip, 'reset_confirm');
|
||||
|
||||
$body = read_json_object();
|
||||
$token = (string)($body->token ?? '');
|
||||
$password = (string)($body->password ?? '');
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
error_out(422, 'validation');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, email, expires_at FROM password_resets
|
||||
WHERE token_hash = ?'
|
||||
);
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$reset = $stmt->fetch();
|
||||
|
||||
if (
|
||||
!is_array($reset)
|
||||
|| (int)$reset['expires_at'] < time()
|
||||
) {
|
||||
error_out(400, 'token_invalid');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE users SET pass_hash = ? WHERE email = ?');
|
||||
$stmt->execute([
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
(string)$reset['email'],
|
||||
]);
|
||||
|
||||
// Токен одноразовый: удаляем использованный; заодно чистим просроченные.
|
||||
$stmt = $pdo->prepare('DELETE FROM password_resets WHERE id = ?');
|
||||
$stmt->execute([(int)$reset['id']]);
|
||||
$stmt = $pdo->prepare('DELETE FROM password_resets WHERE expires_at < ?');
|
||||
$stmt->execute([time()]);
|
||||
|
||||
json_out(['ok' => true]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('password-reset/confirm: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
93
server/api/password-reset/request.php
Normal file
93
server/api/password-reset/request.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /api/password-reset/request.php {email}
|
||||
* → ВСЕГДА 200 {ok:true} для несуществующих/невалидных email
|
||||
* (anti-enumeration) | 403 | 413 | 422 (невалидный JSON) | 429 | 503
|
||||
* {error:"mail_unavailable"}.
|
||||
*
|
||||
* Существующему email: токен bin2hex(random_bytes(32)), в БД — sha256
|
||||
* токена и expires = time() + 3600; письмо mail() со ссылкой
|
||||
* {base_url}/password-reset.html?token=… (plain text, From: mail_from).
|
||||
* Rate limit: 5 за 10 минут на IP.
|
||||
* Контракт: docs/INTERFACES.md, раздел «Кабинет 2.0 … (этап 8.1)».
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../../lib/http.php';
|
||||
require __DIR__ . '/../../lib/db.php';
|
||||
require __DIR__ . '/../../lib/ratelimit.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
/**
|
||||
* Отправка письма восстановления. В тестовом режиме (GOLEARN_TEST=1)
|
||||
* письмо пишется в файл (путь из GOLEARN_TEST_MAIL, дефолт
|
||||
* /tmp/go-learn-mail.log) вместо mail(); в проде env отсутствует.
|
||||
*/
|
||||
function send_reset_mail(string $to, string $subject, string $text, string $from): bool
|
||||
{
|
||||
if (getenv('GOLEARN_TEST') === '1') {
|
||||
$file = getenv('GOLEARN_TEST_MAIL');
|
||||
if (!is_string($file) || $file === '') {
|
||||
$file = '/tmp/go-learn-mail.log';
|
||||
}
|
||||
$data = "To: {$to}\nFrom: {$from}\nSubject: {$subject}\n\n{$text}\n----\n";
|
||||
return file_put_contents($file, $data, FILE_APPEND | LOCK_EX) !== false;
|
||||
}
|
||||
return mail($to, $subject, $text, 'From: ' . $from);
|
||||
}
|
||||
|
||||
try {
|
||||
method_must('POST');
|
||||
|
||||
$pdo = db();
|
||||
$ip = client_ip();
|
||||
rate_check($pdo, $ip, 'reset_request', GOLEARN_RATE_MAX_RESET_REQUEST);
|
||||
require_client_header();
|
||||
rate_record($pdo, $ip, 'reset_request');
|
||||
|
||||
$body = read_json_object();
|
||||
$email = trim((string)($body->email ?? ''));
|
||||
|
||||
// Anti-enumeration: невалидный/несуществующий email — тот же 200.
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
|
||||
$stmt->execute([$email]);
|
||||
if ($stmt->fetch() === false) {
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO password_resets (email, token_hash, expires_at)
|
||||
VALUES (?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$email, hash('sha256', $token), time() + 3600]);
|
||||
|
||||
$config = golearn_config();
|
||||
$baseUrl = rtrim((string)($config['base_url'] ?? ''), '/');
|
||||
$mailFrom = (string)($config['mail_from'] ?? '');
|
||||
$link = $baseUrl . '/password-reset.html?token=' . $token;
|
||||
|
||||
$subject = 'Восстановление пароля — Го';
|
||||
$text = "Здравствуйте!\n\n"
|
||||
. "Вы запросили восстановление пароля на сайте обучения игре Го.\n"
|
||||
. "Чтобы задать новый пароль, перейдите по ссылке:\n\n"
|
||||
. $link . "\n\n"
|
||||
. "Ссылка действует 1 час. Если вы не запрашивали восстановление,\n"
|
||||
. "просто проигнорируйте это письмо — пароль не изменится.\n";
|
||||
|
||||
if (!send_reset_mail($email, $subject, $text, $mailFrom)) {
|
||||
error_out(503, 'mail_unavailable');
|
||||
}
|
||||
|
||||
json_out(['ok' => true]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('password-reset/request: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
65
server/api/progress.php
Normal file
65
server/api/progress.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GET /api/progress.php → 200 {payload, updatedAt} | 204 (прогресса нет) | 401.
|
||||
* PUT /api/progress.php {payload} → 200 {updatedAt} | 401 | 403 | 413
|
||||
* (тело > 256 КБ) | 422 (payload не объект).
|
||||
* Payload хранится как есть (opaque JSON), updated_at — серверное время.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../lib/http.php';
|
||||
require __DIR__ . '/../lib/db.php';
|
||||
require __DIR__ . '/../lib/auth.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('GET', 'PUT');
|
||||
|
||||
$pdo = db();
|
||||
$user = require_user($pdo);
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT payload, updated_at FROM progress WHERE user_id = ?'
|
||||
);
|
||||
$stmt->execute([$user['id']]);
|
||||
$row = $stmt->fetch();
|
||||
if (!is_array($row)) {
|
||||
no_content_out();
|
||||
}
|
||||
json_out([
|
||||
'payload' => json_decode((string)$row['payload']),
|
||||
'updatedAt' => (string)$row['updated_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
// PUT — мутирующий эндпоинт: нужен X-GoLearn-Client: web.
|
||||
require_client_header();
|
||||
|
||||
$body = read_json_object();
|
||||
if (!isset($body->payload) || !is_object($body->payload)) {
|
||||
error_out(422, 'validation');
|
||||
}
|
||||
|
||||
$updatedAt = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO progress (user_id, payload, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
payload = excluded.payload,
|
||||
updated_at = excluded.updated_at'
|
||||
);
|
||||
$stmt->execute([
|
||||
$user['id'],
|
||||
json_encode($body->payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$updatedAt,
|
||||
]);
|
||||
|
||||
json_out(['updatedAt' => $updatedAt]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('progress: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
74
server/api/register.php
Normal file
74
server/api/register.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /api/register.php {email, password, captcha}
|
||||
* → 201 {ok:true} | 403 | 413 | 422 (невалидный email / пароль < 8 /
|
||||
* капча) | 409 (email занят) | 429.
|
||||
*
|
||||
* Этап 8.1: обязательное поле captcha (код из /api/captcha.php).
|
||||
* Проверка одноразовая: ЛЮБАЯ попытка register сжигает код из сессии.
|
||||
* Порядок проверок по контракту: rate limit (429) → заголовок (403)
|
||||
* → капча (422 captcha) → валидация (422) → занят (409) → 201.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../lib/http.php';
|
||||
require __DIR__ . '/../lib/db.php';
|
||||
require __DIR__ . '/../lib/auth.php';
|
||||
require __DIR__ . '/../lib/ratelimit.php';
|
||||
|
||||
http_security_headers();
|
||||
|
||||
try {
|
||||
method_must('POST');
|
||||
|
||||
$pdo = db();
|
||||
$ip = client_ip();
|
||||
rate_check($pdo, $ip, 'register');
|
||||
require_client_header();
|
||||
rate_record($pdo, $ip, 'register');
|
||||
|
||||
$body = read_json_object();
|
||||
$email = trim((string)($body->email ?? ''));
|
||||
$password = (string)($body->password ?? '');
|
||||
$captcha = (string)($body->captcha ?? '');
|
||||
|
||||
// Капча — одноразовая: код изымается из сессии при любой попытке.
|
||||
session_start_secure();
|
||||
$captchaHash = $_SESSION['captcha_hash'] ?? null;
|
||||
$captchaExpires = $_SESSION['captcha_expires'] ?? null;
|
||||
unset($_SESSION['captcha_hash'], $_SESSION['captcha_expires']);
|
||||
$captchaOk = is_string($captchaHash)
|
||||
&& is_int($captchaExpires)
|
||||
&& $captchaExpires >= time()
|
||||
&& $captcha !== ''
|
||||
&& hash_equals($captchaHash, hash('sha256', strtolower($captcha)));
|
||||
if (!$captchaOk) {
|
||||
error_out(422, 'captcha');
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
|
||||
error_out(422, 'validation');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
|
||||
$stmt->execute([$email]);
|
||||
if ($stmt->fetch() !== false) {
|
||||
error_out(409, 'email_taken');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (email, pass_hash, created_at) VALUES (?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$email,
|
||||
password_hash($password, PASSWORD_DEFAULT),
|
||||
gmdate('Y-m-d\TH:i:s\Z'),
|
||||
]);
|
||||
|
||||
json_out(['ok' => true], 201);
|
||||
} catch (Throwable $e) {
|
||||
error_log('register: ' . $e->getMessage());
|
||||
error_out(500, 'internal');
|
||||
}
|
||||
26
server/config.example.php
Normal file
26
server/config.example.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Пример конфигурации серверного API (этап 8).
|
||||
*
|
||||
* Скопируйте этот файл в config.php (рядом, в server/) и укажите реальный
|
||||
* путь к файлу БД. config.php НЕ коммитится (см. корневой .gitignore).
|
||||
*
|
||||
* [SECRET]: путь к файлу БД. Файл БД обязан лежать ВНЕ web root хостинга.
|
||||
*
|
||||
* Переменная окружения GOLEARN_DB (если задана) имеет приоритет —
|
||||
* используется smoke-тестом для подмены БД на временный файл.
|
||||
*
|
||||
* Этап 8.1 (восстановление пароля):
|
||||
* [SECRET]: base_url — публичный origin сайта (без завершающего слэша);
|
||||
* вписывается в ссылку восстановления пароля в письме.
|
||||
* [SECRET]: mail_from — адрес отправителя писем (From:); на shared-хостинге
|
||||
* обязан совпадать с почтовым ящиком домена (SPF/DKIM панели).
|
||||
*/
|
||||
return [
|
||||
'db_path' => getenv('GOLEARN_DB') ?: __DIR__ . '/../../go-learn.sqlite',
|
||||
'base_url' => 'https://example.com',
|
||||
'mail_from' => 'noreply@example.com',
|
||||
];
|
||||
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]);
|
||||
}
|
||||
288
server/tests/smoke.sh
Normal file
288
server/tests/smoke.sh
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
#!/bin/sh
|
||||
# Smoke-тест серверного API этапов 8 и 8.1 (контракт: docs/INTERFACES.md).
|
||||
# sh, без bashизмов. Поднимает php -S на server/ с временной БД в /tmp
|
||||
# (через env GOLEARN_DB) и гонит curl-сценарий. Код выхода != 0 при любом
|
||||
# фейле; php -S убивается, временные файлы удаляются.
|
||||
#
|
||||
# Этап 8.1: сервер поднимается с GOLEARN_TEST=1 — captcha.php отдаёт код
|
||||
# в заголовке X-Captcha-Debug, а письма восстановления пароля пишутся
|
||||
# в файл GOLEARN_TEST_MAIL вместо mail().
|
||||
#
|
||||
# Использование:
|
||||
# sh server/tests/smoke.sh # PHP по умолчанию: /tmp/php-run
|
||||
# PHP=/path/to/php sh server/tests/smoke.sh
|
||||
# PORT=8100 sh server/tests/smoke.sh
|
||||
|
||||
set -u
|
||||
|
||||
PHP=${PHP:-/tmp/php-run}
|
||||
PORT=${PORT:-8099}
|
||||
BASE="http://127.0.0.1:$PORT"
|
||||
SERVER_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
|
||||
TMPD=$(mktemp -d /tmp/golearn-smoke.XXXXXX) || exit 1
|
||||
DB="$TMPD/test.sqlite"
|
||||
COOKIES="$TMPD/cookies.txt"
|
||||
RESP="$TMPD/resp.txt"
|
||||
SRVLOG="$TMPD/server.log"
|
||||
BIG="$TMPD/big.json"
|
||||
HEADERS="$TMPD/headers.txt"
|
||||
CAPTCHA="$TMPD/captcha.svg"
|
||||
MAIL="$TMPD/mail.log"
|
||||
export GOLEARN_DB="$DB"
|
||||
export GOLEARN_TEST=1
|
||||
export GOLEARN_TEST_MAIL="$MAIL"
|
||||
|
||||
SRV_PID=""
|
||||
FAIL=0
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$SRV_PID" ]; then
|
||||
kill "$SRV_PID" 2>/dev/null
|
||||
wait "$SRV_PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$DB" "$DB-wal" "$DB-shm" "$COOKIES" "$RESP" "$SRVLOG" "$BIG" \
|
||||
"$HEADERS" "$CAPTCHA" "$MAIL"
|
||||
rmdir "$TMPD" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# req METHOD PATH DATA CLIENT
|
||||
# DATA: строка JSON, @path — файл, '-' — без тела
|
||||
# CLIENT: 'yes' — добавить X-GoLearn-Client: web, 'no' — без заголовка
|
||||
# Печатает HTTP-код; тело ответа остаётся в $RESP.
|
||||
req() {
|
||||
_method=$1
|
||||
_path=$2
|
||||
_data=$3
|
||||
_client=$4
|
||||
set -- -s -o "$RESP" -w '%{http_code}' -X "$_method" \
|
||||
-b "$COOKIES" -c "$COOKIES"
|
||||
if [ "$_client" = "yes" ]; then
|
||||
set -- "$@" -H 'X-GoLearn-Client: web'
|
||||
fi
|
||||
if [ "$_data" != "-" ]; then
|
||||
set -- "$@" -H 'Content-Type: application/json' \
|
||||
--data-binary "$_data"
|
||||
fi
|
||||
curl "$@" "$BASE$_path"
|
||||
}
|
||||
|
||||
# get_captcha — GET /api/captcha.php с куки-джаром; заголовки ответа
|
||||
# остаются в $HEADERS, SVG — в $CAPTCHA; печатает код из X-Captcha-Debug
|
||||
# (тестовый режим GOLEARN_TEST=1).
|
||||
get_captcha() {
|
||||
curl -s -D "$HEADERS" -o "$CAPTCHA" \
|
||||
-b "$COOKIES" -c "$COOKIES" "$BASE/api/captcha.php"
|
||||
tr -d '\r' <"$HEADERS" \
|
||||
| sed -n 's/^[Xx]-[Cc]aptcha-[Dd]ebug:[[:space:]]*//p' | head -n 1
|
||||
}
|
||||
|
||||
check() {
|
||||
# check ОПИСАНИЕ ОЖИДАЕМЫЙ_КОД ФАКТИЧЕСКИЙ_КОД
|
||||
if [ "$2" = "$3" ]; then
|
||||
echo "ok [$3] $1"
|
||||
else
|
||||
echo "FAIL [$3, ожидали $2] $1"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
check_body() {
|
||||
# check_body ОПИСАНИЕ ПАТТЕРН (фиксированная строка, grep -qF по $RESP)
|
||||
if grep -qF "$2" "$RESP"; then
|
||||
echo "ok [body] $1"
|
||||
else
|
||||
echo "FAIL [body] $1 — тело: $(cat "$RESP")"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
check_hdr() {
|
||||
# check_hdr ОПИСАНИЕ ПАТТЕРН (regexp, grep -qiE по $HEADERS)
|
||||
if grep -qiE "$2" "$HEADERS"; then
|
||||
echo "ok [hdr] $1"
|
||||
else
|
||||
echo "FAIL [hdr] $1 — заголовки: $(tr '\n' ' ' <"$HEADERS")"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- поднять сервер ---
|
||||
"$PHP" -S 127.0.0.1:"$PORT" -t "$SERVER_DIR" >"$SRVLOG" 2>&1 &
|
||||
SRV_PID=$!
|
||||
|
||||
_i=0
|
||||
_ready=0
|
||||
while [ "$_i" -lt 50 ]; do
|
||||
if curl -s "$BASE/api/health.php" 2>/dev/null | grep -q '"status":"ok"'; then
|
||||
_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.2 2>/dev/null || sleep 1
|
||||
_i=$((_i + 1))
|
||||
done
|
||||
if [ "$_ready" != "1" ]; then
|
||||
echo "FAIL: php -S не поднялся, лог:"
|
||||
cat "$SRVLOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EMAIL="smoke@example.com"
|
||||
PASS="password123"
|
||||
NEWPASS="newpassword456"
|
||||
|
||||
# --- сценарий по контракту ---
|
||||
_code=$(req GET /api/health.php - no)
|
||||
check "GET health" 200 "$_code"
|
||||
check_body "health status ok" '"status":"ok"'
|
||||
|
||||
# --- этап 8.1: капча ---
|
||||
_code=$(curl -s -D "$HEADERS" -o "$CAPTCHA" -w '%{http_code}' \
|
||||
-b "$COOKIES" -c "$COOKIES" "$BASE/api/captcha.php")
|
||||
check "GET captcha" 200 "$_code"
|
||||
check_hdr "captcha: Content-Type image/svg+xml" \
|
||||
'^content-type:[[:space:]]*image/svg\+xml'
|
||||
check_hdr "captcha: X-Captcha-Debug (GOLEARN_TEST=1)" '^x-captcha-debug:'
|
||||
check_hdr "captcha: Set-Cookie сессии" '^set-cookie:'
|
||||
if grep -qF '<svg' "$CAPTCHA"; then
|
||||
echo "ok [body] captcha: тело — SVG"
|
||||
else
|
||||
echo "FAIL [body] captcha: тело не SVG — $(cat "$CAPTCHA")"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# register без капчи → 422 captcha (код в сессии сгорает при попытке)
|
||||
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" yes)
|
||||
check "register без капчи" 422 "$_code"
|
||||
check_body "register без капчи: error=captcha" '"error":"captcha"'
|
||||
|
||||
# register с неверной капчей → 422 captcha
|
||||
CAP=$(get_captcha)
|
||||
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"00000\"}" yes)
|
||||
check "register с неверной капчей" 422 "$_code"
|
||||
check_body "register неверная капча: error=captcha" '"error":"captcha"'
|
||||
|
||||
# register с верной капчей (код из X-Captcha-Debug, общий куки-джар) → 201
|
||||
CAP=$(get_captcha)
|
||||
if [ -z "$CAP" ]; then
|
||||
echo "FAIL [hdr] X-Captcha-Debug пуст — дальнейшая регистрация невозможна"
|
||||
FAIL=1
|
||||
fi
|
||||
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"$CAP\"}" yes)
|
||||
check "register с верной капчей" 201 "$_code"
|
||||
|
||||
# повторное использование той же капчи → 422 captcha (код одноразовый)
|
||||
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"$CAP\"}" yes)
|
||||
check "register с той же капчей повторно" 422 "$_code"
|
||||
check_body "повтор капчи: error=captcha" '"error":"captcha"'
|
||||
|
||||
# повторный register того же email (со свежей капчей) → 409
|
||||
CAP=$(get_captcha)
|
||||
_code=$(req POST /api/register.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"captcha\":\"$CAP\"}" yes)
|
||||
check "повторный register" 409 "$_code"
|
||||
|
||||
_code=$(req POST /api/register.php "{\"email\":\"other@example.com\",\"password\":\"$PASS\"}" no)
|
||||
check "register без X-GoLearn-Client" 403 "$_code"
|
||||
|
||||
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"wrong-password\"}" yes)
|
||||
check "login с неверным паролем" 401 "$_code"
|
||||
|
||||
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" yes)
|
||||
check "login" 200 "$_code"
|
||||
|
||||
_code=$(req GET /api/me.php - no)
|
||||
check "me" 200 "$_code"
|
||||
check_body "me возвращает email" "\"email\":\"$EMAIL\""
|
||||
|
||||
_code=$(req PUT /api/progress.php '{"payload":{"lesson":3,"done":["a","b"]}}' yes)
|
||||
check "PUT progress" 200 "$_code"
|
||||
check_body "PUT progress отдаёт updatedAt" '"updatedAt":"'
|
||||
|
||||
_code=$(req GET /api/progress.php - no)
|
||||
check "GET progress" 200 "$_code"
|
||||
check_body "GET progress: payload совпадает" '"lesson":3'
|
||||
check_body "GET progress: массив done совпадает" '"done":["a","b"]'
|
||||
|
||||
# тело > 256 КБ → 413
|
||||
{ printf '{"payload":{"blob":"'; head -c 300000 /dev/zero | tr '\0' 'a'; printf '"}}'; } >"$BIG"
|
||||
_code=$(req PUT /api/progress.php "@$BIG" yes)
|
||||
check "PUT progress с телом >256 КБ" 413 "$_code"
|
||||
|
||||
# --- этап 8.1: восстановление пароля ---
|
||||
_code=$(req POST /api/password-reset/request.php '{"email":"no-such-user@example.com"}' yes)
|
||||
check "reset request: несуществующий email" 200 "$_code"
|
||||
check_body "reset request несуществующий: ok" '"ok":true'
|
||||
|
||||
_code=$(req POST /api/password-reset/request.php "{\"email\":\"$EMAIL\"}" yes)
|
||||
check "reset request: существующий email" 200 "$_code"
|
||||
check_body "reset request существующий: ok" '"ok":true'
|
||||
|
||||
# письмо в тестовом режиме пишется в файл GOLEARN_TEST_MAIL
|
||||
if [ -f "$MAIL" ] && grep -qF 'token=' "$MAIL"; then
|
||||
echo "ok [mail] письмо содержит ссылку с token="
|
||||
else
|
||||
echo "FAIL [mail] письмо без token= — файл: $(cat "$MAIL" 2>/dev/null)"
|
||||
FAIL=1
|
||||
fi
|
||||
TOKEN=$(tr -d '\r' <"$MAIL" 2>/dev/null \
|
||||
| sed -n 's/.*token=\([0-9a-f]\{64\}\).*/\1/p' | tail -n 1)
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo "ok [mail] токен извлечён из письма"
|
||||
else
|
||||
echo "FAIL [mail] токен не извлекается из письма"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
_code=$(req POST /api/password-reset/confirm.php '{"token":"0000000000000000000000000000000000000000000000000000000000000000","password":"whatever123"}' yes)
|
||||
check "reset confirm: неверный токен" 400 "$_code"
|
||||
check_body "reset confirm неверный: error=token_invalid" '"error":"token_invalid"'
|
||||
|
||||
_code=$(req POST /api/password-reset/confirm.php "{\"token\":\"$TOKEN\",\"password\":\"short\"}" yes)
|
||||
check "reset confirm: пароль < 8" 422 "$_code"
|
||||
|
||||
_code=$(req POST /api/password-reset/confirm.php "{\"token\":\"$TOKEN\",\"password\":\"$NEWPASS\"}" yes)
|
||||
check "reset confirm: верный токен" 200 "$_code"
|
||||
check_body "reset confirm верный: ok" '"ok":true'
|
||||
|
||||
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" yes)
|
||||
check "login со СТАРЫМ паролем после reset" 401 "$_code"
|
||||
|
||||
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"$NEWPASS\"}" yes)
|
||||
check "login с НОВЫМ паролем после reset" 200 "$_code"
|
||||
|
||||
# rate limit reset request: 5 за 10 минут; уже сделано 2, 6-я → 429
|
||||
_i=3
|
||||
while [ "$_i" -le 5 ]; do
|
||||
_code=$(req POST /api/password-reset/request.php "{\"email\":\"$EMAIL\"}" yes)
|
||||
check "reset request, попытка $_i/5" 200 "$_code"
|
||||
_i=$((_i + 1))
|
||||
done
|
||||
_code=$(req POST /api/password-reset/request.php "{\"email\":\"$EMAIL\"}" yes)
|
||||
check "reset request: 6-я попытка за 10 минут" 429 "$_code"
|
||||
check_body "reset request 429: error=rate_limited" '"error":"rate_limited"'
|
||||
|
||||
# rate limit login: 10 попыток за 10 минут, 11-я → 429
|
||||
_i=1
|
||||
while [ "$_i" -le 10 ]; do
|
||||
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"bad-pass-$_i\"}" yes)
|
||||
check "login неверный, попытка $_i/10" 401 "$_code"
|
||||
_i=$((_i + 1))
|
||||
done
|
||||
_code=$(req POST /api/login.php "{\"email\":\"$EMAIL\",\"password\":\"bad-pass-11\"}" yes)
|
||||
check "login: 11-я попытка за 10 минут" 429 "$_code"
|
||||
check_body "429 error=rate_limited" '"error":"rate_limited"'
|
||||
|
||||
_code=$(req POST /api/logout.php - yes)
|
||||
check "logout" 200 "$_code"
|
||||
|
||||
_code=$(req GET /api/me.php - no)
|
||||
check "me после logout" 401 "$_code"
|
||||
|
||||
echo "---"
|
||||
if [ "$FAIL" = "0" ]; then
|
||||
echo "SMOKE OK"
|
||||
else
|
||||
echo "SMOKE FAILED"
|
||||
fi
|
||||
exit "$FAIL"
|
||||
Loading…
Add table
Add a link
Reference in a new issue