feat(security): Phase 1 — harden auth, rate limiting, CSRF, upload validation

Security hardening covering credentials, brute-force protection, CSRF,
TOCTOU races, upload validation, and migration failure handling.

Secret rotation is deferred; the existing secrets in .env will be
rotated in a later phase. This phase reduces the attack surface and
removes the most exploitable issues.

Removed:
- Hardcoded 'super'/'admin' super-admin credentials in
  src/app/api/admin/login/route.ts. Username/hash are now loaded from
  env (SUPER_ADMIN_USERNAME / SUPER_ADMIN_PASSWORD_HASH) and verified
  via bcrypt like regular admins.

Added:
- src/lib/config.ts: single source of truth for APP_DOMAIN,
  APP_URL, subdomain regex/lengths, validation bounds, admin password
  policy, image-key allow-list regex, and super-admin env credentials.
- src/lib/rate-limit.ts: in-memory LRU (via lru-cache) rate limiters —
  admin login (5/min), validate-code (20/min), check-subdomain
  (60/min), upload (10/min) — keyed by client IP, returning 429 with
  X-RateLimit-* headers.
- requireAdmin / requireAdminPost / requireSuperAdminPost guards in
  src/lib/admin-session.ts. All admin mutating routes now enforce
  same-origin (Origin/Referer/Host check against NEXT_PUBLIC_APP_URL)
  before running — CSRF protection for the custom admin auth layer.
- Logout now imports COOKIE_NAME_ADMIN instead of hardcoding the string.
- Server-side magic-byte detection for image uploads (no new dep) —
  rejects spoofed Content-Type. GIF removed from allowed types.
- /api/publish is now transactional (prisma.) with
  explicit P2002 → 409 handling for subdomain collisions.
- /api/image validates the key against an allow-list regex and returns
  Macedonian error messages (was the only English-localized file).
- /api/validate-code enforces a 12-hex-char pattern and uses
  updateMany with usedByUserId=null guard to make the claim atomic.
- /api/check-subdomain validates the slug against the shared regex
  before hitting the DB and returns a short private Cache-Control.
- Admin password minimum length bumped from 6 to 12 with letter+digit
  complexity requirement across change-password, users POST and
  users/[id] PUT.

Changed:
- scripts/start.sh: prisma migrate deploy failures now exit non-zero
  instead of silently continuing (prevents schema drift in prod).
- .env.example: documents SUPER_ADMIN_USERNAME /
  SUPER_ADMIN_PASSWORD_HASH with example bcrypt-hash generation.
- package.json: lru-cache added as direct dependency (already present
  transitively, promoted to explicit).
This commit is contained in:
dimitar 2026-08-02 10:24:28 +02:00
parent 2418553172
commit 3ff24cda0b
21 changed files with 466 additions and 151 deletions

View File

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

10
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" } }
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AdminSession | null> {
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)();
};
}

28
src/lib/config.ts Normal file
View File

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

61
src/lib/rate-limit.ts Normal file
View File

@ -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<string, Bucket>;
private maxTokens: number;
private windowMs: number;
constructor(maxTokens: number, windowMs: number, capacity = 10000) {
this.maxTokens = maxTokens;
this.windowMs = windowMs;
this.cache = new LRUCache<string, Bucket>({
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<string, string> {
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)),
};
}

View File

@ -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<AllowedImageType, Array<(buf: Buffer) => 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),
};
}
}