Harden CMS security and theme contrast

This commit is contained in:
Simo committed 2026-07-11 20:27:20 +02:00
1 parent 2465ff2170
commit 4a1e1115b3
57 files changed
+1023 -231

No files matched your search

+65 -22
View File
@@ -1,11 +1,18 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
import { prisma } from "@/lib/prisma";
import { captureOrder, creditsPerUnit, isPayPalConfigured } from "@/lib/services/paypal";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import {
authorizeTopupCapture,
claimTopupDelivery,
TopupCaptureError,
} from "@/lib/services/paypal-topup";
import { logServerError } from "@/lib/server-log";
export const dynamic = "force-dynamic";
@@ -29,8 +36,8 @@ export async function POST(req: Request): Promise<Response> {
if (!session?.user?.id) {
return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) {
const userId = sessionUserId(session.user.id);
if (!userId) {
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
}
@@ -53,22 +60,49 @@ export async function POST(req: Request): Promise<Response> {
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
}
// Idempotency: if this order was already recorded, don't capture/credit again.
// The create endpoint records ownership before returning the approval URL.
// Do not let a signed-in user submit somebody else's approved order id.
let authorized;
try {
const existing = await prisma.websitePaypalTransactions.findFirst({
where: { transactionId: orderId },
select: { id: true, status: true },
});
if (existing) {
authorized = await authorizeTopupCapture(userId, orderId, async (transactionId) => prisma.websitePaypalTransactions.findFirst({
where: { transactionId },
select: { userId: true, status: true, amount: true },
}));
} catch (error) {
if (error instanceof TopupCaptureError) {
const status = error.code === "ORDER_NOT_FOUND" ? 404 : 409;
return NextResponse.json({ error: "Order not found or already processed." }, { status });
}
return NextResponse.json({ error: "Could not verify the payment order." }, { status: 500 });
}
if (authorized.action === "DELIVER_CREDITS") {
const amount = authorized.amount ?? 0;
const credits = Math.floor(amount * creditsPerUnit());
try {
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}));
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
data: { status: "COMPLETED", updatedAt: new Date() },
});
return NextResponse.json({
ok: existing.status === "COMPLETED",
alreadyProcessed: true,
status: existing.status,
ok: true,
recovered: true,
status: "COMPLETED",
amount,
credits,
});
} catch (error) {
logServerError("paypal.credit_recovery_failed", error, { userId, orderId });
return NextResponse.json(
{ error: "Payment is recorded but credits could not be delivered. Contact staff." },
{ status: 500 },
);
}
} catch {
// If the lookup fails we fall through; the capture call itself is the source
// of truth and PayPal rejects a second capture of the same order.
}
let result;
@@ -85,10 +119,9 @@ export async function POST(req: Request): Promise<Response> {
if (result.status !== "COMPLETED") {
// Record the non-completed attempt so support can trace it.
try {
await prisma.websitePaypalTransactions.create({
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREATED" },
data: {
userId,
transactionId: result.id || orderId,
status: result.status,
description: `${env.HOTEL_NAME} top-up (not completed)`,
amount: result.amount,
@@ -111,11 +144,10 @@ export async function POST(req: Request): Promise<Response> {
// Record the transaction BEFORE crediting so a crash mid-grant can't be
// reprocessed into a double credit (the idempotency check above keys on this).
try {
await prisma.websitePaypalTransactions.create({
const claimed = await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREATED" },
data: {
userId,
transactionId: result.captureId ?? result.id,
status: "COMPLETED",
status: "CAPTURED_PENDING_CREDIT",
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
amount: result.amount,
currency: result.currency,
@@ -123,6 +155,7 @@ export async function POST(req: Request): Promise<Response> {
updatedAt: new Date(),
},
});
if (claimed.count !== 1) throw new Error("Top-up order was already claimed");
} catch (e) {
logger.error("PayPal capture record failed", { module: "paypal/capture", error: (e as Error).message });
return NextResponse.json(
@@ -131,9 +164,19 @@ export async function POST(req: Request): Promise<Response> {
);
}
// Credit the buyer's website credits wallet (RCON-first, DB fallback).
// Atomically claim delivery so concurrent retries cannot grant twice. If the
// external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible
// for staff reconciliation instead of automatically risking a second grant.
try {
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}));
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
data: { status: "COMPLETED", updatedAt: new Date() },
});
} catch (e) {
logger.error("PayPal capture credit failed", { module: "paypal/capture", error: (e as Error).message });
return NextResponse.json(
+12
View File
@@ -1,8 +1,11 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
import { prisma } from "@/lib/prisma";
import { createOrder, creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import { recordCreatedTopup } from "@/lib/services/paypal-topup";
export const dynamic = "force-dynamic";
@@ -21,6 +24,10 @@ export async function POST(req: Request): Promise<Response> {
if (!session?.user?.id) {
return NextResponse.json({ error: "You must be signed in to top up." }, { status: 401 });
}
const userId = sessionUserId(session.user.id);
if (!userId) {
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
}
// Fail fast (and clearly) when the sandbox/live keys aren't set.
if (!isPayPalConfigured()) {
@@ -63,6 +70,11 @@ export async function POST(req: Request): Promise<Response> {
);
}
await recordCreatedTopup(
{ userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits },
(data) => prisma.websitePaypalTransactions.create({ data }),
);
return NextResponse.json({
id: order.id,
approveUrl: order.approveUrl,