Add admin panel, gallery, and security hardening.

Replace forgeable cookie auth with signed JWT sessions, protect admin APIs, add input validation, and improve Docker deployment config.
This commit is contained in:
Atakan Doğan Özban
2026-07-12 00:34:46 +02:00
parent 91dbfa7d77
commit d74c422de6
31 changed files with 2749 additions and 153 deletions
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/auth";
import { parseLocationBody } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try {
const { id } = await params;
const body = await request.json();
const parsed = parseLocationBody(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const data = parsed.data;
const location = await prisma.location.update({
where: { id },
data: {
title: data.title,
city: data.city,
gpxData: data.gpxData ?? null,
tags: data.tags,
captureDate: new Date(data.captureDate),
visibility: data.visibility,
},
});
return NextResponse.json(location);
} catch (error) {
console.error("Error updating location:", error);
return NextResponse.json(
{ error: "Failed to update location" },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try {
const { id } = await params;
await prisma.location.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting location:", error);
return NextResponse.json(
{ error: "Failed to delete location" },
{ status: 500 }
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/auth";
import { parseLocationBody } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try {
const locations = await prisma.location.findMany({
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(locations);
} catch (error) {
console.error("Error fetching all locations:", error);
return NextResponse.json(
{ error: "Failed to fetch locations" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
const auth = await requireAdmin();
if (!auth.authorized) return auth.response;
try {
const body = await request.json();
const parsed = parseLocationBody(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const data = parsed.data;
const location = await prisma.location.create({
data: {
title: data.title,
city: data.city,
gpxData: data.gpxData ?? null,
tags: data.tags,
captureDate: new Date(data.captureDate),
visibility: data.visibility,
},
});
return NextResponse.json(location);
} catch (error) {
console.error("Error creating location:", error);
return NextResponse.json(
{ error: "Failed to create location" },
{ status: 500 }
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import {
createSessionToken,
getSessionCookieOptions,
} from "@/lib/session";
import { safeCompare } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rate-limit";
function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return request.headers.get("x-real-ip") ?? "unknown";
}
export async function POST(request: Request) {
try {
const ip = getClientIp(request);
if (!checkRateLimit(`login:${ip}`)) {
return NextResponse.json(
{ error: "Too many login attempts. Try again later." },
{ status: 429 }
);
}
const { username, password } = await request.json();
const correctPassword = process.env.ADMIN_PASSWORD;
const correctUsername = process.env.ADMIN_USERNAME;
if (!correctPassword || !correctUsername) {
console.error(
"ADMIN_PASSWORD and ADMIN_USERNAME environment variables must be set."
);
return NextResponse.json(
{ error: "Server Configuration Error" },
{ status: 500 }
);
}
if (
typeof username !== "string" ||
typeof password !== "string" ||
!safeCompare(username, correctUsername) ||
!safeCompare(password, correctPassword)
) {
return NextResponse.json(
{ error: "Invalid credentials." },
{ status: 401 }
);
}
const token = await createSessionToken();
const response = NextResponse.json({ success: true });
response.cookies.set({
...getSessionCookieOptions(),
value: token,
});
return response;
} catch (error) {
console.error("Login error:", error);
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { getSessionCookieOptions, SESSION_COOKIE } from "@/lib/session";
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set({
...getSessionCookieOptions(0),
name: SESSION_COOKIE,
value: "",
});
return response;
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json({
apiKey: process.env.GOOGLE_MAPS_API_KEY || "",
});
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const locations = await prisma.location.findMany({
where: {
visibility: "public",
},
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(locations);
} catch (error) {
console.error("Error fetching public locations:", error);
return NextResponse.json({ error: "Failed to fetch locations" }, { status: 500 });
}
}