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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || "",
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user