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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { addGroupBooking } from "@/lib/db";
|
import { addGroupBooking } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, phone, groupInfo, instagram, telegram } = body;
|
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { addMcRegistration } from "@/lib/db";
|
import { addMcRegistration } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { masterClassTitle, name, phone, instagram, telegram } = body;
|
const { masterClassTitle, name, phone, instagram, telegram } = body;
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ import {
|
|||||||
getPersonOpenDayBookings,
|
getPersonOpenDayBookings,
|
||||||
getOpenDayEvent,
|
getOpenDayEvent,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 10, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { classId, eventId, name, phone, instagram, telegram } = body;
|
const { classId, eventId, name, phone, instagram, telegram } = body;
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function Classes({ data: classes }: ClassesProps) {
|
|||||||
src={item.images[0]}
|
src={item.images[0]}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
|
sizes="(min-width: 1024px) 60vw, 100vw"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
{/* Gradient overlay */}
|
{/* Gradient overlay */}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ function MasterClassCard({
|
|||||||
src={item.image}
|
src={item.image}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function FeaturedArticle({
|
|||||||
src={item.image}
|
src={item.image}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="(min-width: 768px) 80vw, 100vw"
|
sizes="(min-width: 768px) 80vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
@@ -84,6 +85,7 @@ function CompactArticle({
|
|||||||
src={item.image}
|
src={item.image}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="112px"
|
sizes="112px"
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
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}
|
src={m.image}
|
||||||
alt={m.name}
|
alt={m.name}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="280px"
|
sizes="280px"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
draggable={false}
|
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) {
|
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(
|
const rows = db.prepare(
|
||||||
"SELECT * FROM mc_registrations WHERE master_class_title = ?"
|
`SELECT * FROM mc_registrations WHERE master_class_title IN (${placeholders})`
|
||||||
).all(title) as McRegistrationRow[];
|
).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) {
|
for (const r of rows) {
|
||||||
|
const info = titleInfo.get(r.master_class_title);
|
||||||
|
if (!info) continue;
|
||||||
items.push({
|
items.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
type: "master-class",
|
type: "master-class",
|
||||||
@@ -717,8 +744,8 @@ export function getUpcomingReminders(): ReminderItem[] {
|
|||||||
instagram: r.instagram ?? undefined,
|
instagram: r.instagram ?? undefined,
|
||||||
telegram: r.telegram ?? undefined,
|
telegram: r.telegram ?? undefined,
|
||||||
reminderStatus: r.reminder_status ?? undefined,
|
reminderStatus: r.reminder_status ?? undefined,
|
||||||
eventLabel: `${title}${time ? ` · ${time}` : ""}`,
|
eventLabel: `${r.master_class_title}${info.time ? ` · ${info.time}` : ""}`,
|
||||||
eventDate: date,
|
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