From 127990e532d6ab5ebd286d1edff7f0c356af0760 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Thu, 19 Mar 2026 13:55:49 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20critical=20perf=20&=20security=20?= =?UTF-8?q?=E2=80=94=20rate=20limiting,=20DB=20indexes,=20N+1=20query,=20i?= =?UTF-8?q?mage=20lazy=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add in-memory rate limiter (src/lib/rateLimit.ts) to public registration endpoints - Add DB migration #9 with 8 performance indexes on booking/registration tables - Fix N+1 query in getUpcomingReminders() — single IN() query instead of per-title - Add loading="lazy" to all non-hero images (MasterClasses, News, Classes, Team) - Add sizes attribute to Classes images for better responsive loading Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/group-booking/route.ts | 9 +++ src/app/api/master-class-register/route.ts | 9 +++ src/app/api/open-day-register/route.ts | 9 +++ src/components/sections/Classes.tsx | 2 + src/components/sections/MasterClasses.tsx | 1 + src/components/sections/News.tsx | 2 + src/components/sections/team/TeamCarousel.tsx | 1 + src/lib/db.ts | 37 +++++++++-- src/lib/rateLimit.ts | 62 +++++++++++++++++++ 9 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/lib/rateLimit.ts diff --git a/src/app/api/group-booking/route.ts b/src/app/api/group-booking/route.ts index a8d8337..f409fcf 100644 --- a/src/app/api/group-booking/route.ts +++ b/src/app/api/group-booking/route.ts @@ -1,7 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import { addGroupBooking } from "@/lib/db"; +import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { + const ip = getClientIp(request); + if (!checkRateLimit(ip, 5, 60_000)) { + return NextResponse.json( + { error: "Слишком много запросов. Попробуйте через минуту." }, + { status: 429 } + ); + } + try { const body = await request.json(); const { name, phone, groupInfo, instagram, telegram } = body; diff --git a/src/app/api/master-class-register/route.ts b/src/app/api/master-class-register/route.ts index dd60809..d43eb2e 100644 --- a/src/app/api/master-class-register/route.ts +++ b/src/app/api/master-class-register/route.ts @@ -1,7 +1,16 @@ import { NextResponse } from "next/server"; import { addMcRegistration } from "@/lib/db"; +import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; export async function POST(request: Request) { + const ip = getClientIp(request); + if (!checkRateLimit(ip, 5, 60_000)) { + return NextResponse.json( + { error: "Слишком много запросов. Попробуйте через минуту." }, + { status: 429 } + ); + } + try { const body = await request.json(); const { masterClassTitle, name, phone, instagram, telegram } = body; diff --git a/src/app/api/open-day-register/route.ts b/src/app/api/open-day-register/route.ts index e3987be..64ae02d 100644 --- a/src/app/api/open-day-register/route.ts +++ b/src/app/api/open-day-register/route.ts @@ -5,8 +5,17 @@ import { getPersonOpenDayBookings, getOpenDayEvent, } from "@/lib/db"; +import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { + const ip = getClientIp(request); + if (!checkRateLimit(ip, 10, 60_000)) { + return NextResponse.json( + { error: "Слишком много запросов. Попробуйте через минуту." }, + { status: 429 } + ); + } + try { const body = await request.json(); const { classId, eventId, name, phone, instagram, telegram } = body; diff --git a/src/components/sections/Classes.tsx b/src/components/sections/Classes.tsx index 19a5ee2..3684233 100644 --- a/src/components/sections/Classes.tsx +++ b/src/components/sections/Classes.tsx @@ -53,6 +53,8 @@ export function Classes({ data: classes }: ClassesProps) { src={item.images[0]} alt={item.name} fill + loading="lazy" + sizes="(min-width: 1024px) 60vw, 100vw" className="object-cover" /> {/* Gradient overlay */} diff --git a/src/components/sections/MasterClasses.tsx b/src/components/sections/MasterClasses.tsx index 6ad9586..eff67b0 100644 --- a/src/components/sections/MasterClasses.tsx +++ b/src/components/sections/MasterClasses.tsx @@ -98,6 +98,7 @@ function MasterClassCard({ src={item.image} alt={item.title} fill + loading="lazy" sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" className="object-cover transition-transform duration-700 group-hover:scale-110" /> diff --git a/src/components/sections/News.tsx b/src/components/sections/News.tsx index 21b498f..71e9f83 100644 --- a/src/components/sections/News.tsx +++ b/src/components/sections/News.tsx @@ -42,6 +42,7 @@ function FeaturedArticle({ src={item.image} alt={item.title} fill + loading="lazy" sizes="(min-width: 768px) 80vw, 100vw" className="object-cover transition-transform duration-700 group-hover:scale-105" /> @@ -84,6 +85,7 @@ function CompactArticle({ src={item.image} alt={item.title} fill + loading="lazy" sizes="112px" className="object-cover transition-transform duration-500 group-hover:scale-105" /> diff --git a/src/components/sections/team/TeamCarousel.tsx b/src/components/sections/team/TeamCarousel.tsx index f364e7c..9d37a04 100644 --- a/src/components/sections/team/TeamCarousel.tsx +++ b/src/components/sections/team/TeamCarousel.tsx @@ -222,6 +222,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou src={m.image} alt={m.name} fill + loading="lazy" sizes="280px" className="object-cover" draggable={false} diff --git a/src/lib/db.ts b/src/lib/db.ts index 5547f44..2b26211 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -184,6 +184,22 @@ const migrations: Migration[] = [ } }, }, + { + version: 9, + name: "add_performance_indexes", + up: (db) => { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_mc_registrations_title ON mc_registrations(master_class_title); + CREATE INDEX IF NOT EXISTS idx_mc_registrations_created ON mc_registrations(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_group_bookings_created ON group_bookings(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_open_day_bookings_event ON open_day_bookings(event_id); + CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class ON open_day_bookings(class_id); + CREATE INDEX IF NOT EXISTS idx_open_day_bookings_phone ON open_day_bookings(event_id, phone); + CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class_phone ON open_day_bookings(class_id, phone); + CREATE INDEX IF NOT EXISTS idx_open_day_classes_event ON open_day_classes(event_id); + `); + }, + }, ]; function runMigrations(db: Database.Database) { @@ -703,11 +719,22 @@ export function getUpcomingReminders(): ReminderItem[] { } } } - for (const { title, date, time } of upcomingTitles) { + if (upcomingTitles.length > 0) { + const uniqueTitles = [...new Set(upcomingTitles.map((t) => t.title))]; + const placeholders = uniqueTitles.map(() => "?").join(", "); const rows = db.prepare( - "SELECT * FROM mc_registrations WHERE master_class_title = ?" - ).all(title) as McRegistrationRow[]; + `SELECT * FROM mc_registrations WHERE master_class_title IN (${placeholders})` + ).all(...uniqueTitles) as McRegistrationRow[]; + + // Build a lookup: title → { date, time } + const titleInfo = new Map(); + for (const t of upcomingTitles) { + titleInfo.set(t.title, { date: t.date, time: t.time }); + } + for (const r of rows) { + const info = titleInfo.get(r.master_class_title); + if (!info) continue; items.push({ id: r.id, type: "master-class", @@ -717,8 +744,8 @@ export function getUpcomingReminders(): ReminderItem[] { instagram: r.instagram ?? undefined, telegram: r.telegram ?? undefined, reminderStatus: r.reminder_status ?? undefined, - eventLabel: `${title}${time ? ` · ${time}` : ""}`, - eventDate: date, + eventLabel: `${r.master_class_title}${info.time ? ` · ${info.time}` : ""}`, + eventDate: info.date, }); } } diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts new file mode 100644 index 0000000..7494241 --- /dev/null +++ b/src/lib/rateLimit.ts @@ -0,0 +1,62 @@ +/** + * Simple in-memory rate limiter for public API endpoints. + * Limits requests per IP within a sliding time window. + */ + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const store = new Map(); + +// Periodically clean up expired entries (every 5 minutes) +let cleanupScheduled = false; +function scheduleCleanup() { + if (cleanupScheduled) return; + cleanupScheduled = true; + setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (now > entry.resetAt) store.delete(key); + } + }, 5 * 60 * 1000); +} + +/** + * Check if a request is within the rate limit. + * @param ip - Client IP address + * @param limit - Max requests per window (default: 10) + * @param windowMs - Time window in ms (default: 60_000 = 1 minute) + * @returns true if allowed, false if rate limited + */ +export function checkRateLimit( + ip: string, + limit: number = 10, + windowMs: number = 60_000 +): boolean { + scheduleCleanup(); + + const now = Date.now(); + const entry = store.get(ip); + + if (!entry || now > entry.resetAt) { + store.set(ip, { count: 1, resetAt: now + windowMs }); + return true; + } + + if (entry.count >= limit) return false; + entry.count++; + return true; +} + +/** + * Extract client IP from request headers. + */ +export function getClientIp(request: Request): string { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown" + ); +}