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:
parent
2418553172
commit
3ff24cda0b
@ -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
10
package-lock.json
generated
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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 } });
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
28
src/lib/config.ts
Normal 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
61
src/lib/rate-limit.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user