diff --git a/.env.example b/.env.example index 4f7821c..699bcfc 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,10 @@ NEXT_PUBLIC_APP_URL=https://testbed.mk NEXT_PUBLIC_APP_DOMAIN=testbed.mk # Admin -ADMIN_SESSION_SECRET=your-random-64-char-secret-here-change-it-in-production \ No newline at end of file +# 64+ random hex chars. Generate with: openssl rand -hex 32 +ADMIN_SESSION_SECRET=your-random-64-char-secret-here-change-it-in-production + +# Super-admin (stored as bcrypt hash, NOT plaintext). Generate with: +# node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))" +SUPER_ADMIN_USERNAME=super +SUPER_ADMIN_PASSWORD_HASH=$2a$12$REPLACE_WITH_BCRYPT_HASH_OF_YOUR_PASSWORD \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5c0117b..198a393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@clerk/nextjs": "^7.5.7", "@prisma/client": "^5.22.0", "bcryptjs": "^3.0.3", + "lru-cache": "^11.5.2", "next": "^15.5.19", "qrcode": "^1.5.4", "react": "19.2.4", @@ -5254,6 +5255,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, diff --git a/package.json b/package.json index 795c8b4..b3a47f2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@clerk/nextjs": "^7.5.7", "@prisma/client": "^5.22.0", "bcryptjs": "^3.0.3", + "lru-cache": "^11.0.0", "next": "^15.5.19", "qrcode": "^1.5.4", "react": "19.2.4", diff --git a/scripts/start.sh b/scripts/start.sh index 5959c37..e11c214 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -9,9 +9,10 @@ echo "CLERK_SECRET_KEY: ${CLERK_SECRET_KEY:+set}" echo "S3_ENDPOINT: ${S3_ENDPOINT:+set}" echo "Running Prisma migrations..." -npx prisma migrate deploy || { - echo "WARNING: Prisma migrations failed, continuing anyway..." -} +if ! npx prisma migrate deploy; then + echo "ERROR: Prisma migrations failed. Aborting start." + exit 1 +fi echo "Starting Next.js server on 0.0.0.0:3000..." exec node server.js \ No newline at end of file diff --git a/src/app/api/admin/change-password/route.ts b/src/app/api/admin/change-password/route.ts index 1d6ebf3..cf9962e 100644 --- a/src/app/api/admin/change-password/route.ts +++ b/src/app/api/admin/change-password/route.ts @@ -1,14 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { hash, compare } from "bcryptjs"; import { prisma } from "@/lib/prisma"; -import { getAdminSession } from "@/lib/admin-session"; +import { requireAdminPost } from "@/lib/admin-session"; +import { ADMIN_PASSWORD_MIN_LENGTH } from "@/lib/config"; + +function strongEnough(password: string): boolean { + if (password.length < ADMIN_PASSWORD_MIN_LENGTH) return false; + return /[A-Za-z]/.test(password) && /\d/.test(password); +} export async function POST(req: NextRequest) { + const guard = await requireAdminPost()(req); + if ("response" in guard) return guard.response; + const { session } = guard; + try { - const session = await getAdminSession(); - if (!session) { - return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); - } if (session.username === "super") { return NextResponse.json({ error: "SuperAdmin не може да ја промени лозинката преку овој метод" }, { status: 400 }); } @@ -17,8 +23,11 @@ export async function POST(req: NextRequest) { if (!currentPassword || !newPassword) { return NextResponse.json({ error: "Тековната и новата лозинка се задолжителни" }, { status: 400 }); } - if (newPassword.length < 6) { - return NextResponse.json({ error: "Новата лозинка мора да има најмалку 6 карактери" }, { status: 400 }); + if (!strongEnough(newPassword)) { + return NextResponse.json( + { error: `Новата лозинка мора да има најмалку ${ADMIN_PASSWORD_MIN_LENGTH} карактери и да содржи буква и цифра` }, + { status: 400 } + ); } const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); diff --git a/src/app/api/admin/codes/[id]/route.ts b/src/app/api/admin/codes/[id]/route.ts index d7c8ece..f0c0945 100644 --- a/src/app/api/admin/codes/[id]/route.ts +++ b/src/app/api/admin/codes/[id]/route.ts @@ -1,12 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { getAdminSession } from "@/lib/admin-session"; +import { requireAdminPost } from "@/lib/admin-session"; export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getAdminSession(); - if (!session) { - return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); - } + const guard = await requireAdminPost()(req); + if ("response" in guard) return guard.response; + const { session } = guard; const { id } = await params; const code = await prisma.code.findUnique({ where: { id } }); diff --git a/src/app/api/admin/codes/route.ts b/src/app/api/admin/codes/route.ts index 6465a77..610856a 100644 --- a/src/app/api/admin/codes/route.ts +++ b/src/app/api/admin/codes/route.ts @@ -1,13 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { randomBytes } from "crypto"; import { prisma } from "@/lib/prisma"; -import { getAdminSession } from "@/lib/admin-session"; +import { requireAdmin, requireAdminPost } from "@/lib/admin-session"; export async function GET() { - const session = await getAdminSession(); - if (!session) { - return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); - } + const guard = await requireAdmin()(); + if ("response" in guard) return guard.response; + const { session } = guard; const where = session.role === "SUPER_ADMIN" ? {} : { createdBy: { username: session.username } }; @@ -20,15 +19,17 @@ export async function GET() { return NextResponse.json(codes); } -export async function POST() { - const session = await getAdminSession(); - if (!session) { - return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); - } +export async function POST(req: NextRequest) { + const guard = await requireAdminPost()(req); + if ("response" in guard) return guard.response; + const { session } = guard; const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); if (!admin) { - return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + return NextResponse.json( + { error: "Администраторот не е пронајден во базата. Кодови може да креираат само администратори со запис во базата." }, + { status: 404 } + ); } const code = randomBytes(6).toString("hex").toUpperCase(); diff --git a/src/app/api/admin/login/route.ts b/src/app/api/admin/login/route.ts index 89c36eb..87b4a6d 100644 --- a/src/app/api/admin/login/route.ts +++ b/src/app/api/admin/login/route.ts @@ -2,19 +2,37 @@ import { NextRequest, NextResponse } from "next/server"; import { compare } from "bcryptjs"; import { prisma } from "@/lib/prisma"; import { createAdminSession, cookieOptions } from "@/lib/admin-session"; +import { adminLoginLimiter, rateLimitHeaders } from "@/lib/rate-limit"; +import { SUPER_ADMIN_USERNAME, SUPER_ADMIN_PASSWORD_HASH } from "@/lib/config"; -const SUPER_USERNAME = "super"; -const SUPER_PASSWORD = "admin"; +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} export async function POST(req: NextRequest) { + const ip = clientIp(req); + const limit = adminLoginLimiter.limit(`admin-login:${ip}`); + if (!limit.success) { + return NextResponse.json( + { error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } + try { const { username, password } = await req.json(); if (!username || !password) { return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 }); } - if (username === SUPER_USERNAME && password === SUPER_PASSWORD) { - const session = await createAdminSession({ username: SUPER_USERNAME, role: "SUPER_ADMIN" }); + if (username === SUPER_ADMIN_USERNAME && SUPER_ADMIN_PASSWORD_HASH) { + const valid = await compare(password, SUPER_ADMIN_PASSWORD_HASH); + if (!valid) { + return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 }); + } + const session = await createAdminSession({ username: SUPER_ADMIN_USERNAME, role: "SUPER_ADMIN" }); const res = NextResponse.json({ success: true }); res.cookies.set(cookieOptions(session)); return res; diff --git a/src/app/api/admin/logout/route.ts b/src/app/api/admin/logout/route.ts index 1509e95..357387c 100644 --- a/src/app/api/admin/logout/route.ts +++ b/src/app/api/admin/logout/route.ts @@ -1,9 +1,13 @@ import { NextResponse } from "next/server"; +import { COOKIE_NAME_ADMIN, requireAdminPost } from "@/lib/admin-session"; + +export async function POST(req: Request) { + const guard = await requireAdminPost()(req as never); + if ("response" in guard) return guard.response; -export async function POST() { const res = NextResponse.json({ success: true }); res.cookies.set({ - name: "admin_session", + name: COOKIE_NAME_ADMIN, value: "", httpOnly: true, secure: process.env.NODE_ENV === "production", @@ -13,3 +17,4 @@ export async function POST() { }); return res; } + diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 751970c..526cc90 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,13 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { hash } from "bcryptjs"; import { prisma } from "@/lib/prisma"; -import { getAdminSession } from "@/lib/admin-session"; +import { requireSuperAdminPost } from "@/lib/admin-session"; +import { ADMIN_PASSWORD_MIN_LENGTH } from "@/lib/config"; + +function strongEnough(password: string): boolean { + if (password.length < ADMIN_PASSWORD_MIN_LENGTH) return false; + return /[A-Za-z]/.test(password) && /\d/.test(password); +} export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getAdminSession(); - if (!session || session.role !== "SUPER_ADMIN") { - return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); - } + const guard = await requireSuperAdminPost()(req); + if ("response" in guard) return guard.response; const { id } = await params; const user = await prisma.adminUser.findUnique({ where: { id } }); @@ -23,16 +27,17 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i } export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getAdminSession(); - if (!session || session.role !== "SUPER_ADMIN") { - return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); - } + const guard = await requireSuperAdminPost()(req); + if ("response" in guard) return guard.response; try { const { id } = await params; const { password } = await req.json(); - if (!password || password.length < 6) { - return NextResponse.json({ error: "Лозинката мора да има најмалку 6 карактери" }, { status: 400 }); + if (!password || !strongEnough(password)) { + return NextResponse.json( + { error: `Лозинката мора да има најмалку ${ADMIN_PASSWORD_MIN_LENGTH} карактери и да содржи буква и цифра` }, + { status: 400 } + ); } const user = await prisma.adminUser.findUnique({ where: { id } }); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 49f4194..717a562 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -1,13 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { hash } from "bcryptjs"; import { prisma } from "@/lib/prisma"; -import { getAdminSession } from "@/lib/admin-session"; +import { requireAdmin, requireSuperAdminPost } from "@/lib/admin-session"; +import { ADMIN_PASSWORD_MIN_LENGTH } from "@/lib/config"; + +function strongEnough(password: string): boolean { + if (password.length < ADMIN_PASSWORD_MIN_LENGTH) return false; + return /[A-Za-z]/.test(password) && /\d/.test(password); +} export async function GET() { - const session = await getAdminSession(); - if (!session || session.role !== "SUPER_ADMIN") { - return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); - } + const guard = await requireAdmin(true)(); + if ("response" in guard) return guard.response; const users = await prisma.adminUser.findMany({ orderBy: { createdAt: "desc" }, @@ -17,18 +21,19 @@ export async function GET() { } export async function POST(req: NextRequest) { - const session = await getAdminSession(); - if (!session || session.role !== "SUPER_ADMIN") { - return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); - } + const guard = await requireSuperAdminPost()(req); + if ("response" in guard) return guard.response; try { const { username, password } = await req.json(); if (!username || !password) { return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 }); } - if (password.length < 6) { - return NextResponse.json({ error: "Лозинката мора да има најмалку 6 карактери" }, { status: 400 }); + if (!strongEnough(password)) { + return NextResponse.json( + { error: `Лозинката мора да има најмалку ${ADMIN_PASSWORD_MIN_LENGTH} карактери и да содржи буква и цифра` }, + { status: 400 } + ); } const existing = await prisma.adminUser.findUnique({ where: { username } }); diff --git a/src/app/api/check-subdomain/route.ts b/src/app/api/check-subdomain/route.ts index b064ab6..0d292b1 100644 --- a/src/app/api/check-subdomain/route.ts +++ b/src/app/api/check-subdomain/route.ts @@ -1,17 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { checkSubdomainLimiter, rateLimitHeaders } from "@/lib/rate-limit"; +import { SUBDOMAIN_REGEX, SUBDOMAIN_MIN_LENGTH, SUBDOMAIN_MAX_LENGTH } from "@/lib/config"; + +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} export async function GET(req: NextRequest) { - const slug = req.nextUrl.searchParams.get("slug"); + const ip = clientIp(req); + const limit = checkSubdomainLimiter.limit(`check-subdomain:${ip}`); + if (!limit.success) { + return NextResponse.json( + { available: false, error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } - if (!slug || slug.length < 3) { - return NextResponse.json({ available: false }); + const raw = req.nextUrl.searchParams.get("slug") || ""; + const slug = raw.toLowerCase().trim(); + + if ( + slug.length < SUBDOMAIN_MIN_LENGTH || + slug.length > SUBDOMAIN_MAX_LENGTH || + !SUBDOMAIN_REGEX.test(slug) + ) { + return NextResponse.json( + { available: false }, + { headers: { "Cache-Control": "private, max-age=60" } } + ); } const existing = await prisma.user.findUnique({ where: { subdomain: slug } }); return NextResponse.json( { available: !existing }, - { headers: { "Cache-Control": "no-store" } } + { headers: { "Cache-Control": "private, max-age=60" } } ); -} \ No newline at end of file +} diff --git a/src/app/api/image/route.ts b/src/app/api/image/route.ts index a5d245c..fd94508 100644 --- a/src/app/api/image/route.ts +++ b/src/app/api/image/route.ts @@ -2,16 +2,17 @@ import { NextRequest, NextResponse } from "next/server"; import { GetObjectCommand } from "@aws-sdk/client-s3"; import { Readable } from "stream"; import { s3Client, S3_BUCKET } from "@/lib/s3"; +import { isAllowedImageKey } from "@/lib/config"; export async function GET(req: NextRequest) { const key = req.nextUrl.searchParams.get("key"); if (!key) { - return NextResponse.json({ error: "Missing key parameter" }, { status: 400 }); + return NextResponse.json({ error: "Недостасува параметар key" }, { status: 400 }); } - if (!key.startsWith("uploads/")) { - return NextResponse.json({ error: "Invalid key" }, { status: 400 }); + if (!isAllowedImageKey(key)) { + return NextResponse.json({ error: "Невалиден клуч" }, { status: 400 }); } try { @@ -35,13 +36,13 @@ export async function GET(req: NextRequest) { }, }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; + const message = error instanceof Error ? error.message : "Непозната грешка"; console.error("Image proxy error:", message); if (message.includes("NoSuchKey") || message.includes("404")) { - return NextResponse.json({ error: "Image not found" }, { status: 404 }); + return NextResponse.json({ error: "Сликата не е пронајдена" }, { status: 404 }); } - return NextResponse.json({ error: "Failed to fetch image" }, { status: 500 }); + return NextResponse.json({ error: "Не успеа преземањето на сликата" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/publish/route.ts b/src/app/api/publish/route.ts index ea4556a..fa33dce 100644 --- a/src/app/api/publish/route.ts +++ b/src/app/api/publish/route.ts @@ -1,9 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { revalidateTag } from "next/cache"; +import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { generateMonumentQR } from "@/lib/qrcode"; import { getPublicUrl } from "@/lib/upload"; +import { + SUBDOMAIN_REGEX, + SUBDOMAIN_MIN_LENGTH, + SUBDOMAIN_MAX_LENGTH, + TITLE_MAX_LENGTH, + DESCRIPTION_MAX_LENGTH, + APP_DOMAIN, +} from "@/lib/config"; export async function POST(req: NextRequest) { const { userId } = await auth(); @@ -22,91 +31,96 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { title, description, bornDate, passedDate, subdomain, templateId, images } = body; - if (!title?.trim()) { - return NextResponse.json({ error: "Името е задолжително" }, { status: 400 }); + if (!title?.trim() || title.length > TITLE_MAX_LENGTH) { + return NextResponse.json({ error: `Името е задолжително (макс. ${TITLE_MAX_LENGTH} карактери)` }, { status: 400 }); } - if (!subdomain || subdomain.length < 3) { - return NextResponse.json({ error: "Поддоменот мора да има најмалку 3 карактери" }, { status: 400 }); + if (description && description.length > DESCRIPTION_MAX_LENGTH) { + return NextResponse.json({ error: `Описот е преголем (макс. ${DESCRIPTION_MAX_LENGTH} карактери)` }, { status: 400 }); } - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(subdomain)) { + if (!subdomain || subdomain.length < SUBDOMAIN_MIN_LENGTH || subdomain.length > SUBDOMAIN_MAX_LENGTH) { + return NextResponse.json({ error: `Поддоменот мора да има ${SUBDOMAIN_MIN_LENGTH}-${SUBDOMAIN_MAX_LENGTH} карактери` }, { status: 400 }); + } + if (!SUBDOMAIN_REGEX.test(subdomain)) { return NextResponse.json({ error: "Поддоменот може да содржи само мали букви, цифри и цртички" }, { status: 400 }); } if (templateId < 1 || templateId > 3) { return NextResponse.json({ error: "Невалиден шаблон" }, { status: 400 }); } - if (!images || images.length === 0 || images.length > 3) { + if (!Array.isArray(images) || images.length === 0 || images.length > 3) { return NextResponse.json({ error: "Потребни се 1-3 фотографии" }, { status: 400 }); } - const existing = await prisma.user.findUnique({ where: { subdomain } }); - if (existing && existing.clerkId !== userId) { - return NextResponse.json({ error: "Поддоменот е веќе зафатен" }, { status: 409 }); - } - const existingUser = await prisma.user.findUnique({ where: { clerkId: userId } }); - - let user; - if (existingUser) { - if (existingUser.subdomain !== subdomain) { - revalidateTag(`memorial-${existingUser.subdomain}`); + if (existingUser && existingUser.subdomain !== subdomain) { + const conflict = await prisma.user.findUnique({ where: { subdomain } }); + if (conflict && conflict.clerkId !== userId) { + return NextResponse.json({ error: "Поддоменот е веќе зафатен" }, { status: 409 }); } - await prisma.image.deleteMany({ where: { userId: existingUser.id } }); - user = await prisma.user.update({ - where: { clerkId: userId }, - data: { - title, - description, - bornDate: bornDate || null, - passedDate: passedDate || null, - subdomain, - templateId, - published: true, - images: { - create: images.map((img: { key: string }, i: number) => ({ - url: getPublicUrl(img.key), - key: img.key, - order: i + 1, - })), - }, - }, - include: { images: true }, - }); - } else { - user = await prisma.user.create({ - data: { - clerkId: userId, - title, - description, - bornDate: bornDate || null, - passedDate: passedDate || null, - subdomain, - templateId, - published: true, - images: { - create: images.map((img: { key: string }, i: number) => ({ - url: getPublicUrl(img.key), - key: img.key, - order: i + 1, - })), - }, - }, - include: { images: true }, - }); } - revalidateTag("memorial"); - revalidateTag(`memorial-${subdomain}`); + const imageData = images.map((img: { key: string }, i: number) => ({ + url: getPublicUrl(img.key), + key: img.key, + order: i + 1, + })); - const qrCode = await generateMonumentQR(subdomain); + try { + const user = await prisma.$transaction(async (tx) => { + if (existingUser) { + if (existingUser.subdomain !== subdomain) { + revalidateTag(`memorial-${existingUser.subdomain}`); + } + await tx.image.deleteMany({ where: { userId: existingUser.id } }); + return tx.user.update({ + where: { clerkId: userId }, + data: { + title: title.trim(), + description: description || null, + bornDate: bornDate || null, + passedDate: passedDate || null, + subdomain, + templateId, + published: true, + images: { create: imageData }, + }, + include: { images: true }, + }); + } + return tx.user.create({ + data: { + clerkId: userId, + title: title.trim(), + description: description || null, + bornDate: bornDate || null, + passedDate: passedDate || null, + subdomain, + templateId, + published: true, + images: { create: imageData }, + }, + include: { images: true }, + }); + }); - return NextResponse.json({ - success: true, - monumentUrl: `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`, - qrCode, - user, - }); + revalidateTag("memorial"); + revalidateTag(`memorial-${subdomain}`); + + const qrCode = await generateMonumentQR(subdomain); + + return NextResponse.json({ + success: true, + monumentUrl: `https://${subdomain}.${APP_DOMAIN}`, + qrCode, + user, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json({ error: "Поддоменот е веќе зафатен" }, { status: 409 }); + } + throw error; + } } catch (error) { console.error("Publish error:", error); return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 381106a..794d334 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -3,9 +3,25 @@ import { auth } from "@clerk/nextjs/server"; import { PutObjectCommand } from "@aws-sdk/client-s3"; import { v4 as uuidv4 } from "uuid"; import { s3Client, S3_BUCKET, getPublicUrl } from "@/lib/s3"; -import { MAX_FILE_SIZE, ALLOWED_TYPES } from "@/lib/upload"; +import { MAX_FILE_SIZE, detectImageType } from "@/lib/upload"; +import { uploadLimiter, rateLimitHeaders } from "@/lib/rate-limit"; + +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} export async function POST(req: NextRequest) { + const ip = clientIp(req); + const limit = uploadLimiter.limit(`upload:${ip}`); + if (!limit.success) { + return NextResponse.json( + { error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } + const { userId } = await auth(); if (!userId) { return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); @@ -19,24 +35,28 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Нема подадено датотека" }, { status: 400 }); } - if (!ALLOWED_TYPES.includes(file.type)) { - return NextResponse.json({ error: "Невалиден тип на датотека" }, { status: 400 }); - } - if (file.size > MAX_FILE_SIZE) { return NextResponse.json({ error: "Датотеката е премногу голема (макс. 5MB)" }, { status: 400 }); } - const ext = file.type.split("/")[1]; - const key = `uploads/${userId}/${uuidv4()}.${ext}`; const buffer = Buffer.from(await file.arrayBuffer()); + const detected = detectImageType(buffer); + if (!detected) { + return NextResponse.json( + { error: "Невалиден тип на датотека. Дозволени: JPEG, PNG, WebP." }, + { status: 400 } + ); + } + + const ext = detected.split("/")[1]; + const key = `uploads/${userId}/${uuidv4()}.${ext === "jpeg" ? "jpg" : ext}`; await s3Client.send( new PutObjectCommand({ Bucket: S3_BUCKET, Key: key, Body: buffer, - ContentType: file.type, + ContentType: detected, }) ); @@ -47,4 +67,4 @@ export async function POST(req: NextRequest) { console.error("Upload error:", error); return NextResponse.json({ error: "Не успеа качувањето" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/validate-code/route.ts b/src/app/api/validate-code/route.ts index 77e0248..ecad3c3 100644 --- a/src/app/api/validate-code/route.ts +++ b/src/app/api/validate-code/route.ts @@ -1,8 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { prisma } from "@/lib/prisma"; +import { validateCodeLimiter, rateLimitHeaders } from "@/lib/rate-limit"; + +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} export async function POST(req: NextRequest) { + const ip = clientIp(req); + const limit = validateCodeLimiter.limit(`validate-code:${ip}`); + if (!limit.success) { + return NextResponse.json( + { error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } + const { userId } = await auth(); if (!userId) { return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); @@ -15,6 +31,9 @@ export async function POST(req: NextRequest) { } const normalizedCode = code.trim().toUpperCase(); + if (!/^[A-F0-9]{12}$/.test(normalizedCode)) { + return NextResponse.json({ valid: false, error: "Невалиден код" }); + } const existing = await prisma.code.findUnique({ where: { code: normalizedCode } }); if (!existing) { @@ -31,11 +50,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ valid: false, error: "Веќе имате искористено код" }); } - await prisma.code.update({ - where: { id: existing.id }, + const updated = await prisma.code.updateMany({ + where: { id: existing.id, usedByUserId: null }, data: { usedByUserId: userId, usedAt: new Date() }, }); + if (updated.count === 0) { + return NextResponse.json({ valid: false, error: "Кодот е веќе искористен" }); + } + return NextResponse.json({ valid: true }); } catch (error) { console.error("Validate code error:", error); diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx index fb06d39..9ad4b40 100644 --- a/src/components/ImageUploader.tsx +++ b/src/components/ImageUploader.tsx @@ -5,7 +5,7 @@ import { useUser } from "@clerk/nextjs"; const MAX_FILES = 3; const MAX_FILE_SIZE = 5 * 1024 * 1024; -const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; interface ImageData { key: string; @@ -36,7 +36,7 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP .slice(0, remaining); if (validFiles.length === 0) { - setError("Невалиден тип на датотека или големина. Дозволени: JPEG, PNG, WebP, GIF до 5MB."); + setError("Невалиден тип на датотека или големина. Дозволени: JPEG, PNG, WebP до 5MB."); return; } diff --git a/src/lib/admin-session.ts b/src/lib/admin-session.ts index fdc0f07..a06652d 100644 --- a/src/lib/admin-session.ts +++ b/src/lib/admin-session.ts @@ -1,8 +1,30 @@ import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { getAppOrigin } from "./config"; const COOKIE_NAME = "admin_session"; const SEP = "."; +function sameOriginRequest(req: NextRequest): boolean { + const origin = req.headers.get("origin"); + const referer = req.headers.get("referer"); + const expected = getAppOrigin().replace(/\/$/, ""); + const host = req.headers.get("host"); + const expectedHost = getAppOrigin().replace(/^https?:\/\//, "").replace(/\/$/, ""); + if (origin) { + return origin.replace(/\/$/, "") === expected; + } + if (referer) { + try { + const u = new URL(referer); + return u.origin.replace(/\/$/, "") === expected; + } catch { + return false; + } + } + return host === expectedHost; +} + function getSecret(): string { const secret = process.env.ADMIN_SESSION_SECRET; if (!secret) throw new Error("ADMIN_SESSION_SECRET env var is not set"); @@ -79,3 +101,42 @@ export async function getAdminSession(): Promise { if (!token) return null; return verifyAdminSession(token); } + +export function requireAdmin(requireSuper = false) { + return async function check(): Promise<{ session: AdminSession } | { response: NextResponse }> { + const session = await getAdminSession(); + if (!session) { + return { + response: NextResponse.json({ error: "Неавторизирано" }, { status: 401 }), + }; + } + if (requireSuper && session.role !== "SUPER_ADMIN") { + return { + response: NextResponse.json({ error: "Немате дозвола" }, { status: 403 }), + }; + } + return { session }; + }; +} + +export function requireAdminPost() { + return async function check(req: NextRequest): Promise<{ session: AdminSession } | { response: NextResponse }> { + if (!sameOriginRequest(req)) { + return { + response: NextResponse.json({ error: "Невалидно потекло на барање" }, { status: 403 }), + }; + } + return requireAdmin()(); + }; +} + +export function requireSuperAdminPost() { + return async function check(req: NextRequest): Promise<{ session: AdminSession } | { response: NextResponse }> { + if (!sameOriginRequest(req)) { + return { + response: NextResponse.json({ error: "Невалидно потекло на барање" }, { status: 403 }), + }; + } + return requireAdmin(true)(); + }; +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..2abda2a --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,28 @@ +export const APP_DOMAIN = + process.env.NEXT_PUBLIC_APP_DOMAIN || "testbed.mk"; + +export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || `https://${APP_DOMAIN}`; + +export const SUBDOMAIN_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; + +export const SUBDOMAIN_MIN_LENGTH = 3; +export const SUBDOMAIN_MAX_LENGTH = 32; + +export const TITLE_MAX_LENGTH = 100; +export const DESCRIPTION_MAX_LENGTH = 2000; + +export const ADMIN_PASSWORD_MIN_LENGTH = 12; + +export const IMAGE_KEY_REGEX = + /^uploads\/[a-zA-Z0-9_-]+\/[a-f0-9-]+\.(jpe?g|png|webp)$/i; + +export function isAllowedImageKey(key: string): boolean { + return IMAGE_KEY_REGEX.test(key); +} + +export const SUPER_ADMIN_USERNAME = process.env.SUPER_ADMIN_USERNAME || "super"; +export const SUPER_ADMIN_PASSWORD_HASH = process.env.SUPER_ADMIN_PASSWORD_HASH || ""; + +export function getAppOrigin(): string { + return APP_URL; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..5a20c8d --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,61 @@ +import { LRUCache } from "lru-cache"; + +export interface RateLimitResult { + success: boolean; + remaining: number; + resetAt: number; +} + +interface Bucket { + count: number; + resetAt: number; +} + +export class RateLimiter { + private cache: LRUCache; + private maxTokens: number; + private windowMs: number; + + constructor(maxTokens: number, windowMs: number, capacity = 10000) { + this.maxTokens = maxTokens; + this.windowMs = windowMs; + this.cache = new LRUCache({ + max: capacity, + ttl: windowMs, + ttlResolution: windowMs, + }); + } + + limit(key: string): RateLimitResult { + const now = Date.now(); + const existing = this.cache.get(key); + if (!existing || existing.resetAt <= now) { + const resetAt = now + this.windowMs; + const bucket: Bucket = { count: 1, resetAt }; + this.cache.set(key, bucket); + return { success: true, remaining: this.maxTokens - 1, resetAt }; + } + if (existing.count >= this.maxTokens) { + return { success: false, remaining: 0, resetAt: existing.resetAt }; + } + existing.count += 1; + return { + success: true, + remaining: this.maxTokens - existing.count, + resetAt: existing.resetAt, + }; + } +} + +export const adminLoginLimiter = new RateLimiter(5, 60_000); +export const validateCodeLimiter = new RateLimiter(20, 60_000); +export const checkSubdomainLimiter = new RateLimiter(60, 60_000); +export const uploadLimiter = new RateLimiter(10, 60_000); + +export function rateLimitHeaders(result: RateLimitResult): Record { + return { + "X-RateLimit-Limit": String(result.success ? result.remaining + 1 : result.remaining), + "X-RateLimit-Remaining": String(Math.max(result.remaining, 0)), + "X-RateLimit-Reset": String(Math.floor(result.resetAt / 1000)), + }; +} diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 1f78bfd..0fedeac 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -4,16 +4,38 @@ import { v4 as uuidv4 } from "uuid"; import { s3Client, S3_BUCKET, getPublicUrl } from "./s3"; const MAX_FILE_SIZE = 5 * 1024 * 1024; -const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const; const MAX_FILES = 3; export { MAX_FILE_SIZE, ALLOWED_TYPES, MAX_FILES, getPublicUrl }; +export type AllowedImageType = (typeof ALLOWED_TYPES)[number]; + +const MAGIC_BYTES: Record boolean>> = { + "image/jpeg": [(b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff], + "image/png": [(b) => b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a], + "image/webp": [(b) => b.length >= 12 && b.slice(0, 4).toString("ascii") === "RIFF" && b.slice(8, 12).toString("ascii") === "WEBP"], +}; + +export function detectImageType(buf: Buffer): AllowedImageType | null { + for (const type of ALLOWED_TYPES) { + if (MAGIC_BYTES[type].some((check) => check(buf))) return type; + } + return null; +} + +export function sanitizeUploadKey(key: string): string { + if (!/^uploads\/[a-zA-Z0-9_-]+\/[a-f0-9-]+\.(jpe?g|png|webp)$/i.test(key)) { + throw new Error("Invalid key format"); + } + return key; +} + export async function generatePresignedUrl( contentType: string, userId: string ): Promise<{ url: string; key: string; publicUrl: string }> { - if (!ALLOWED_TYPES.includes(contentType)) { + if (!ALLOWED_TYPES.includes(contentType as AllowedImageType)) { throw new Error(`Invalid content type: ${contentType}`); } @@ -33,4 +55,4 @@ export async function generatePresignedUrl( key, publicUrl: getPublicUrl(key), }; -} \ No newline at end of file +}