astro_avtourist/frontend/src/pages/api/auth/request-password-reset.ts
2026-05-05 19:33:18 +05:00

196 lines
No EOL
7.5 KiB
TypeScript
Raw 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.

import type { APIRoute } from 'astro';
import { pb } from '../../../lib/pb';
import { sendEmail, getSiteUrl } from '../../../lib/email';
const RATE_LIMIT_MAX_REQUESTS = 3;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
const rateLimitStore = new Map<string, number[]>();
function cleanOldRecords(email: string, now: number) {
const timestamps = rateLimitStore.get(email) || [];
const windowStart = now - RATE_LIMIT_WINDOW_MS;
const validTimestamps = timestamps.filter(ts => ts > windowStart);
if (validTimestamps.length === 0) {
rateLimitStore.delete(email);
} else {
rateLimitStore.set(email, validTimestamps);
}
}
function checkRateLimit(email: string): { allowed: boolean; remaining?: number; resetIn?: number } {
const now = Date.now();
const normalizedEmail = email.toLowerCase().trim();
cleanOldRecords(normalizedEmail, now);
const timestamps = rateLimitStore.get(normalizedEmail) || [];
if (timestamps.length >= RATE_LIMIT_MAX_REQUESTS) {
const oldestTimestamp = timestamps[0];
const resetIn = RATE_LIMIT_WINDOW_MS - (now - oldestTimestamp);
return { allowed: false, resetIn };
}
timestamps.push(now);
rateLimitStore.set(normalizedEmail, timestamps);
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - timestamps.length };
}
function generateResetPasswordHtml(firstName: string, resetLink: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сброс пароля</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f7fa; padding: 40px 20px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.08);">
<tr>
<td style="background: linear-gradient(135deg, #1e3050 0%, #2d4a6f 100%); padding: 30px 40px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Автоюрист Сургут</h1>
<p style="color: rgba(255,255,255,0.8); margin: 10px 0 0 0; font-size: 16px;">Юридические услуги для автовладельцев</p>
</td>
</tr>
<tr>
<td style="padding: 40px;">
<h2 style="color: #1e3050; margin: 0 0 20px 0; font-size: 24px; font-weight: 700;">Сброс пароля</h2>
<p style="color: #64748b; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
Здравствуйте, ${firstName}!
</p>
<p style="color: #64748b; font-size: 16px; line-height: 1.6; margin: 0 0 30px 0;">
Вы запросили сброс пароля. Нажмите кнопку ниже для создания нового пароля:
</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<a href="${resetLink}" style="display: inline-block; background: linear-gradient(135deg, #eac26e 0%, #ce9f40 100%); color: #ffffff; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 700;">
Сбросить пароль
</a>
</td>
</tr>
</table>
<p style="color: #94a3b8; font-size: 14px; margin: 30px 0 0 0;">
Ссылка действительна 1 час. Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо.
</p>
</td>
</tr>
<tr>
<td style="background-color: #1e3050; padding: 24px 40px; text-align: center;">
<p style="color: rgba(255,255,255,0.6); font-size: 14px; margin: 0;">
© 2026 Автоюрист Сургут. Все права защищены.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export const POST: APIRoute = async ({ request }) => {
try {
let body;
try {
body = await request.json();
} catch {
return new Response(
JSON.stringify({ error: 'Неверный формат данных' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const { email } = body;
if (!email) {
return new Response(
JSON.stringify({ error: 'Email обязателен' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const rateLimitResult = checkRateLimit(email);
if (!rateLimitResult.allowed) {
const resetMinutes = Math.ceil((rateLimitResult.resetIn || 0) / 60000);
return new Response(
JSON.stringify({
error: 'Слишком много запросов. Попробуйте позже',
retryAfter: resetMinutes
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(resetMinutes * 60)
}
}
);
}
// Проверяем существует ли пользователь
let user = null;
try {
user = await pb.collection('users').getFirstListItem(`email="${email}"`);
} catch (e) {
console.log('User not found, still return success');
}
if (!user) {
return new Response(
JSON.stringify({
success: true,
message: 'Ссылка для сброса пароля отправлена'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// Создаём свой токен сброса
const resetToken = Buffer.from(`${user.id}:${Date.now()}`).toString('base64').replace(/=/g, '');
const resetLink = `${getSiteUrl()}/auth/reset-password?token=${resetToken}&userId=${user.id}`;
// Отправляем письмо через SMTP.BZ
const firstName = user.firstName || 'Пользователь';
const html = generateResetPasswordHtml(firstName, resetLink);
const emailSent = await sendEmail({
to: email,
subject: 'Сброс пароля — Автоюрист Сургут',
html
});
console.log('Password reset email sent:', emailSent);
if (!emailSent) {
return new Response(
JSON.stringify({ error: 'Не удалось отправить письмо. Попробуйте позже.' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({
success: true,
message: 'Письмо для сброса пароля отправлено'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[PASSWORD_RESET] Error:', error);
return new Response(
JSON.stringify({
error: 'Внутренняя ошибка сервера'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};