- .env.local.example: реальный TELEGRAM_BOT_TOKEN заменён на placeholder (токен скомпрометирован — был в git-истории) - next 14.2.5 -> 14.2.35 (закрывает известные CVE), eslint-config-next синхронно - /api/contact: rate limit 5 запросов / 10 минут по IP (in-memory sliding window) + 429 - build проверен: 12 страниц, ошибок нет
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
|
||
export const runtime = "nodejs";
|
||
|
||
const MAX_LENGTHS = {
|
||
name: 100,
|
||
contact: 150,
|
||
message: 2000,
|
||
};
|
||
|
||
// --- Rate limiting (in-memory, per IP) ---
|
||
// Простой sliding-window лимитер без внешних зависимостей.
|
||
// Для продакшена с несколькими инстансами замените на Redis/внешний стор.
|
||
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10 минут
|
||
const RATE_LIMIT_MAX = 5; // максимум 5 запросов за окно
|
||
const rateStore = new Map<string, { count: number; windowStart: number }>();
|
||
|
||
function getClientIp(req: NextRequest): string {
|
||
const forwarded = req.headers.get("x-forwarded-for");
|
||
if (forwarded) {
|
||
return forwarded.split(",")[0].trim();
|
||
}
|
||
return req.headers.get("x-real-ip") ?? "unknown";
|
||
}
|
||
|
||
function isRateLimited(ip: string): boolean {
|
||
const now = Date.now();
|
||
const entry = rateStore.get(ip);
|
||
if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
||
rateStore.set(ip, { count: 1, windowStart: now });
|
||
return false;
|
||
}
|
||
entry.count += 1;
|
||
return entry.count > RATE_LIMIT_MAX;
|
||
}
|
||
|
||
// Периодическая очистка устаревших записей, чтобы Map не рос бесконечно.
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [ip, entry] of rateStore) {
|
||
if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
||
rateStore.delete(ip);
|
||
}
|
||
}
|
||
}, RATE_LIMIT_WINDOW_MS).unref?.();
|
||
|
||
// Убираем HTML-теги и обрезаем длину, чтобы нельзя было сломать
|
||
// форматирование сообщения в Telegram или протащить лишний код.
|
||
function sanitize(value: unknown, maxLength: number): string {
|
||
if (typeof value !== "string") return "";
|
||
return value.replace(/<[^>]*>/g, "").trim().slice(0, maxLength);
|
||
}
|
||
|
||
// Экранируем спецсимволы Telegram MarkdownV2.
|
||
function escapeMarkdown(text: string): string {
|
||
return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, (m) => `\\${m}`);
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const ip = getClientIp(req);
|
||
if (isRateLimited(ip)) {
|
||
console.warn(`Rate limit exceeded for ${ip}`);
|
||
return NextResponse.json(
|
||
{ ok: false, error: "Слишком много запросов. Попробуйте позже" },
|
||
{ status: 429 }
|
||
);
|
||
}
|
||
|
||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||
const chatId = process.env.TELEGRAM_CHAT_ID;
|
||
|
||
if (!token || !chatId) {
|
||
console.error("TELEGRAM_BOT_TOKEN или TELEGRAM_CHAT_ID не заданы");
|
||
return NextResponse.json(
|
||
{ ok: false, error: "Сервис временно недоступен, попробуйте позже" },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
let body: unknown;
|
||
try {
|
||
body = await req.json();
|
||
} catch {
|
||
return NextResponse.json(
|
||
{ ok: false, error: "Некорректный запрос" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const { name, contact, message } = (body ?? {}) as Record<string, unknown>;
|
||
|
||
const cleanName = sanitize(name, MAX_LENGTHS.name);
|
||
const cleanContact = sanitize(contact, MAX_LENGTHS.contact);
|
||
const cleanMessage = sanitize(message, MAX_LENGTHS.message);
|
||
|
||
if (!cleanName || !cleanContact || !cleanMessage) {
|
||
return NextResponse.json(
|
||
{ ok: false, error: "Заполните имя, контакт и сообщение" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const text = [
|
||
"*Новая заявка с сайта LabNetHub*",
|
||
"",
|
||
`*Имя:* ${escapeMarkdown(cleanName)}`,
|
||
`*Контакт:* ${escapeMarkdown(cleanContact)}`,
|
||
`*Сообщение:*`,
|
||
escapeMarkdown(cleanMessage),
|
||
].join("\n");
|
||
|
||
try {
|
||
const telegramRes = await fetch(
|
||
`https://api.telegram.org/bot${token}/sendMessage`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
chat_id: chatId,
|
||
text,
|
||
parse_mode: "MarkdownV2",
|
||
}),
|
||
}
|
||
);
|
||
|
||
if (!telegramRes.ok) {
|
||
const details = await telegramRes.text();
|
||
console.error("Telegram API error:", details);
|
||
return NextResponse.json(
|
||
{ ok: false, error: "Не удалось отправить сообщение" },
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
|
||
return NextResponse.json({ ok: true });
|
||
} catch (err) {
|
||
console.error("Ошибка отправки в Telegram:", err);
|
||
return NextResponse.json(
|
||
{ ok: false, error: "Не удалось отправить сообщение" },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|