feat: add CSRF protection for admin API routes

Double-submit cookie pattern: login sets bh-csrf-token cookie,
proxy.ts validates X-CSRF-Token header on POST/PUT/DELETE to /api/admin/*.
New adminFetch() helper in src/lib/csrf.ts auto-includes the header.
All admin pages migrated from fetch() to adminFetch().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 17:53:02 +03:00
parent 3ac6a4d840
commit 6cbdba2197
12 changed files with 161 additions and 53 deletions

109
CLAUDE.md
View File

@@ -7,8 +7,9 @@ Content language: Russian
## Tech Stack
- **Next.js 16** (App Router, TypeScript, Turbopack)
- **Tailwind CSS v4** (light + dark mode, class-based toggle)
- **Tailwind CSS v4** (dark mode only, gold/black theme)
- **lucide-react** for icons
- **better-sqlite3** for SQLite database
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
- **Hosting**: Vercel (planned)
@@ -16,66 +17,120 @@ Content language: Russian
- Function declarations for components (not arrow functions)
- PascalCase for component files, camelCase for utils
- `@/` path alias for imports
- Semantic CSS classes via `@apply`: `surface-base`, `surface-muted`, `heading-text`, `body-text`, `nav-link`, `card`, `contact-item`, `contact-icon`, `theme-border`
- Only Header + ThemeToggle are client components (minimal JS shipped)
- `next/image` with `unoptimized` for PNGs that need transparency preserved
- Header nav uses `lg:` breakpoint (1024px) for desktop/mobile switch (9 nav links + CTA need the space)
## Project Structure
```
src/
├── app/
│ ├── layout.tsx # Root layout, fonts, metadata
│ ├── page.tsx # Landing: Hero → Team → About → Classes → Contact
│ ├── page.tsx # Landing: Hero → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
│ ├── globals.css # Tailwind imports
│ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
│ ├── icon.png # Favicon
└── apple-icon.png
│ ├── admin/
│ ├── page.tsx # Dashboard with 11 section cards
│ │ ├── login/ # Password auth
│ │ ├── layout.tsx # Sidebar nav shell
│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor
│ │ ├── meta/ # SEO editor
│ │ ├── hero/ # Hero editor
│ │ ├── about/ # About editor
│ │ ├── team/ # Team list + [id] editor
│ │ ├── classes/ # Classes editor with icon picker
│ │ ├── master-classes/ # MC editor with registrations
│ │ ├── schedule/ # Schedule editor
│ │ ├── pricing/ # Pricing editor
│ │ ├── faq/ # FAQ editor
│ │ ├── news/ # News editor
│ │ └── contact/ # Contact editor
│ └── api/
│ ├── auth/login/ # POST login
│ ├── logout/ # POST logout
│ ├── admin/
│ │ ├── sections/[key]/ # GET/PUT section data
│ │ ├── team/ # CRUD team members
│ │ ├── team/[id]/ # GET/PUT/DELETE single member
│ │ ├── team/reorder/ # PUT reorder
│ │ ├── upload/ # POST file upload (whitelisted folders)
│ │ ├── mc-registrations/ # CRUD registrations
│ │ └── validate-instagram/ # GET check username
│ └── master-class-register/ # POST public signup
├── components/
│ ├── layout/
│ │ ├── Header.tsx # Sticky nav, mobile menu, theme toggle ("use client")
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
│ │ └── Footer.tsx
│ ├── sections/
│ │ ├── Hero.tsx
│ │ ├── Team.tsx # "use client" — clickable cards + modal
│ │ ├── About.tsx
│ │ ├── Classes.tsx
│ │ ── Contact.tsx
│ │ ├── Hero.tsx # Hero with animated logo, floating hearts
│ │ ├── About.tsx # About with stats (trainers, classes, locations)
│ │ ├── Team.tsx # Carousel + profile view
│ │ ├── Classes.tsx # Showcase layout with icon selector
│ │ ── MasterClasses.tsx # Cards with signup modal
│ │ ├── Schedule.tsx # Day/group views with filters
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules
│ │ ├── News.tsx # Featured + compact articles
│ │ ├── FAQ.tsx # Accordion with show more
│ │ └── Contact.tsx # Info + Yandex Maps iframe
│ └── ui/
│ ├── Button.tsx
│ ├── SectionHeading.tsx
│ ├── SocialLinks.tsx
│ ├── ThemeToggle.tsx
│ ├── Reveal.tsx # Intersection Observer scroll reveal
── TeamMemberModal.tsx # "use client" — member popup
│ ├── BookingModal.tsx # Booking form → Instagram DM
│ ├── MasterClassSignupModal.tsx # MC registration form → API
│ ├── NewsModal.tsx # News detail popup
── Reveal.tsx # Intersection Observer scroll reveal
│ ├── BackToTop.tsx
│ └── ...
├── data/
│ └── content.ts # ALL Russian text, structured for future CMS
│ └── content.ts # Fallback Russian text (DB takes priority)
├── lib/
── constants.ts # BRAND constants, NAV_LINKS
── constants.ts # BRAND constants, NAV_LINKS
│ ├── config.ts # UI_CONFIG (thresholds, counts)
│ ├── db.ts # SQLite DB, migrations, CRUD
│ ├── auth.ts # Token signing (Node.js)
│ ├── auth-edge.ts # Token verification (Edge/Web Crypto)
│ └── content.ts # getContent() — DB with fallback
├── proxy.ts # Middleware: auth guard for /admin/*
└── types/
├── index.ts
├── content.ts # SiteContent, TeamMember, ClassItem, ContactInfo
├── content.ts # SiteContent, TeamMember, ClassItem, MasterClassItem, etc.
└── navigation.ts
```
## Brand / Styling
- **Accent**: rose/red (`#e11d48`)
- **Dark mode**: bg `#0a0a0a`, surface `#171717`
- **Light mode**: bg `#fafafa`, surface `#ffffff`
- Logo: transparent PNG, uses `dark:invert` + `unoptimized`
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
- **Background**: `#050505` `#0a0a0a` (dark only)
- **Surface**: `#171717` dark cards
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
## Content Data
- All text lives in `src/data/content.ts` (type-safe, one file to edit)
- 13 team members with photos, Instagram links, and personal descriptions
- Primary source: SQLite database (`db/blackheart.db`)
- Fallback: `src/data/content.ts` (auto-seeds DB on first access)
- Admin panel edits go to DB, site reads from DB via `getContent()`
- 12 team members with photos, Instagram links, bios, victories, education
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
- Master classes with date/time slots and public registration
- 2 addresses in Minsk, Yandex Maps embed with markers
- Contact: phone, Instagram
- Contact: phone, Instagram (no email)
## Admin Panel
- Password-based auth with HMAC-SHA256 signed JWT (24h TTL)
- Cookie: `bh-admin-token` (httpOnly, secure in prod)
- Auto-save with 800ms debounce on all section editors
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education)
- Master classes: slots, registration viewer, trainer/style autocomplete from existing data
- File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
## Security Notes
- **CSRF protection**: Double-submit cookie pattern. Login sets `bh-csrf-token` cookie (JS-readable). All admin fetch calls use `adminFetch()` from `src/lib/csrf.ts` which sends the token as `X-CSRF-Token` header. Middleware (`proxy.ts`) validates header matches cookie on POST/PUT/DELETE to `/api/admin/*`. **Always use `adminFetch()` instead of `fetch()` for admin API calls.**
- File upload validates: MIME type, file extension, whitelisted folder (no path traversal)
- API routes validate: input types, string lengths, numeric IDs
- Public MC registration: length-limited but **no rate limiting yet** (add before production)
## AST Index
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
- Covers all 31 TS/TSX files + 4 CSS files
- Update the index when adding/removing/renaming files or exports
## Database Migrations

View File

@@ -1,5 +1,6 @@
import { useRef, useEffect, useState } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content";
interface InputFieldProps {
@@ -379,7 +380,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
formData.append("file", file);
formData.append("folder", "team");
try {
const res = await fetch("/api/admin/upload", { method: "POST", body: formData });
const res = await adminFetch("/api/admin/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, Check, AlertCircle } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
interface SectionEditorProps<T> {
sectionKey: string;
@@ -24,7 +25,7 @@ export function SectionEditor<T>({
const initialLoadRef = useRef(true);
useEffect(() => {
fetch(`/api/admin/sections/${sectionKey}`)
adminFetch(`/api/admin/sections/${sectionKey}`)
.then((r) => {
if (!r.ok) throw new Error("Failed to load");
return r.json();
@@ -39,7 +40,7 @@ export function SectionEditor<T>({
setError("");
try {
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),

View File

@@ -5,6 +5,7 @@ import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
@@ -335,7 +336,7 @@ function ImageUploadField({
formData.append("file", file);
formData.append("folder", "master-classes");
try {
const res = await fetch("/api/admin/upload", {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});
@@ -506,7 +507,7 @@ function RegistrationRow({
instagram: `@${ig.trim()}`,
telegram: tg.trim() ? `@${tg.trim()}` : undefined,
};
const res = await fetch("/api/admin/mc-registrations", {
const res = await adminFetch("/api/admin/mc-registrations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -647,7 +648,7 @@ function RegistrationsList({ title }: { title: string }) {
useEffect(() => {
if (!title) return;
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setCount(data.length);
@@ -659,7 +660,7 @@ function RegistrationsList({ title }: { title: string }) {
function toggle() {
if (!open && regs.length === 0 && count !== 0) {
setLoading(true);
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setRegs(data);
@@ -680,7 +681,7 @@ function RegistrationsList({ title }: { title: string }) {
instagram: `@${newIg.trim()}`,
telegram: newTg.trim() ? `@${newTg.trim()}` : undefined,
};
const res = await fetch("/api/admin/mc-registrations", {
const res = await adminFetch("/api/admin/mc-registrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -705,7 +706,7 @@ function RegistrationsList({ title }: { title: string }) {
}
async function handleDelete(id: number) {
await fetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
setRegs((prev) => prev.filter((r) => r.id !== id));
setCount((prev) => (prev !== null ? prev - 1 : null));
}
@@ -823,7 +824,7 @@ export default function MasterClassesEditorPage() {
useEffect(() => {
// Fetch trainers from team
fetch("/api/admin/team")
adminFetch("/api/admin/team")
.then((r) => r.json())
.then((members: { name: string }[]) => {
setTrainers(members.map((m) => m.name));
@@ -831,7 +832,7 @@ export default function MasterClassesEditorPage() {
.catch(() => {});
// Fetch styles from classes section
fetch("/api/admin/sections/classes")
adminFetch("/api/admin/sections/classes")
.then((r) => r.json())
.then((data: { items: { name: string }[] }) => {
setStyles(data.items.map((c) => c.name));
@@ -839,7 +840,7 @@ export default function MasterClassesEditorPage() {
.catch(() => {});
// Fetch locations from schedule section
fetch("/api/admin/sections/schedule")
adminFetch("/api/admin/sections/schedule")
.then((r) => r.json())
.then((data: { locations: { name: string; address: string }[] }) => {
setLocations(data.locations);

View File

@@ -5,6 +5,7 @@ import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { NewsItem } from "@/types/content";
interface NewsData {
@@ -30,7 +31,7 @@ function ImageUploadField({
formData.append("file", file);
formData.append("folder", "news");
try {
const res = await fetch("/api/admin/upload", {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
import { Plus, X, Trash2 } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
interface ScheduleData {
@@ -1113,21 +1114,21 @@ export default function ScheduleEditorPage() {
const [classTypes, setClassTypes] = useState<string[]>([]);
useEffect(() => {
fetch("/api/admin/team")
adminFetch("/api/admin/team")
.then((r) => r.json())
.then((members: { name: string }[]) => {
setTrainers(members.map((m) => m.name));
})
.catch(() => {});
fetch("/api/admin/sections/contact")
adminFetch("/api/admin/sections/contact")
.then((r) => r.json())
.then((contact: { addresses?: string[] }) => {
setAddresses(contact.addresses ?? []);
})
.catch(() => {});
fetch("/api/admin/sections/classes")
adminFetch("/api/admin/sections/classes")
.then((r) => r.json())
.then((classes: { items?: { name: string }[] }) => {
setClassTypes((classes.items ?? []).map((c) => c.name));

View File

@@ -5,6 +5,7 @@ import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content";
function extractUsername(value: string): string {
@@ -55,7 +56,7 @@ export default function TeamMemberEditorPage() {
setIgStatus("checking");
igTimerRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
const result = await res.json();
setIgStatus(result.valid ? "valid" : "invalid");
} catch {
@@ -106,7 +107,7 @@ export default function TeamMemberEditorPage() {
useEffect(() => {
if (isNew) return;
fetch(`/api/admin/team/${id}`)
adminFetch(`/api/admin/team/${id}`)
.then((r) => r.json())
.then((member) => {
const username = extractUsername(member.instagram || "");
@@ -139,7 +140,7 @@ export default function TeamMemberEditorPage() {
};
if (isNew) {
const res = await fetch("/api/admin/team", {
const res = await adminFetch("/api/admin/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -148,7 +149,7 @@ export default function TeamMemberEditorPage() {
router.push("/admin/team");
}
} else {
const res = await fetch(`/api/admin/team/${id}`, {
const res = await adminFetch(`/api/admin/team/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -171,7 +172,7 @@ export default function TeamMemberEditorPage() {
formData.append("folder", "team");
try {
const res = await fetch("/api/admin/upload", {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});

View File

@@ -11,6 +11,7 @@ import {
GripVertical,
Check,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { TeamMember } from "@/types/content";
type Member = TeamMember & { id: number };
@@ -29,7 +30,7 @@ export default function TeamEditorPage() {
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
fetch("/api/admin/team")
adminFetch("/api/admin/team")
.then((r) => r.json())
.then(setMembers)
.finally(() => setLoading(false));
@@ -38,7 +39,7 @@ export default function TeamEditorPage() {
const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated);
setSaving(true);
await fetch("/api/admin/team/reorder", {
await adminFetch("/api/admin/team/reorder", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
@@ -159,7 +160,7 @@ export default function TeamEditorPage() {
async function deleteMember(id: number) {
if (!confirm("Удалить этого участника?")) return;
await fetch(`/api/admin/team/${id}`, { method: "DELETE" });
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
setMembers((prev) => prev.filter((m) => m.id !== id));
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyPassword, signToken, COOKIE_NAME } from "@/lib/auth";
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
export async function POST(request: NextRequest) {
const body = await request.json() as { password?: string };
@@ -9,6 +9,7 @@ export async function POST(request: NextRequest) {
}
const token = signToken();
const csrfToken = generateCsrfToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(COOKIE_NAME, token, {
@@ -16,7 +17,15 @@ export async function POST(request: NextRequest) {
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
maxAge: 60 * 60 * 24,
});
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // JS must read this to send as header
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24,
});
return response;

View File

@@ -63,4 +63,10 @@ function verifyTokenNode(token: string): boolean {
}
}
export const CSRF_COOKIE_NAME = "bh-csrf-token";
export function generateCsrfToken(): string {
return crypto.randomBytes(32).toString("base64url");
}
export { COOKIE_NAME };

17
src/lib/csrf.ts Normal file
View File

@@ -0,0 +1,17 @@
const CSRF_COOKIE_NAME = "bh-csrf-token";
function getCsrfToken(): string {
const match = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CSRF_COOKIE_NAME}=`));
return match ? match.split("=")[1] : "";
}
/** Wrapper around fetch that auto-includes the CSRF token header for admin API calls */
export function adminFetch(url: string, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers);
if (!headers.has("x-csrf-token")) {
headers.set("x-csrf-token", getCsrfToken());
}
return fetch(url, { ...init, headers });
}

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
const CSRF_COOKIE_NAME = "bh-csrf-token";
const CSRF_HEADER_NAME = "x-csrf-token";
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -20,6 +24,16 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL("/admin/login", request.url));
}
// CSRF check on state-changing API requests
if (pathname.startsWith("/api/admin/") && STATE_CHANGING_METHODS.has(request.method)) {
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value;
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
return NextResponse.json({ error: "CSRF token mismatch" }, { status: 403 });
}
}
return NextResponse.next();
}