fix: critical perf & security — rate limiting, DB indexes, N+1 query, image lazy loading

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:55:49 +03:00
parent 4e766d6957
commit 127990e532
9 changed files with 127 additions and 5 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 */}

View File

@@ -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"
/>

View File

@@ -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"
/>

View File

@@ -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}

View File

@@ -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<string, { date: string; time?: string }>();
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,
});
}
}

62
src/lib/rateLimit.ts Normal file
View File

@@ -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<string, RateLimitEntry>();
// 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"
);
}