Files
prog1764 d920f1c559 fix: убрать утёкший токен, обновить Next.js, rate limit на /api/contact (PRJ-4)
- .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 страниц, ошибок нет
2026-08-04 19:43:00 +04:00

144 lines
4.6 KiB
TypeScript
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.
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 }
);
}
}