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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
62
src/lib/rateLimit.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user