go-learn/server/api/password-reset/request.php
sab.code.lab 1c85091186 chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:34:02 +02:00

93 lines
3.6 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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');
}