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
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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue