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

+6 -5
View File
@@ -2,18 +2,19 @@
//
// Reads the NextAuth session, then re-queries prisma.user by the session id to
// return a safe field set (never password / auth_ticket / 2FA secrets / pincode
// / mail). Returns { user: null } when unauthenticated or on DB failure.
// / mail). Returns { user: null } only when unauthenticated or missing.
import { apiJson } from "@/lib/api";
import { apiJson, apiUnavailable } from "@/lib/api";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sessionUserId } from "@/lib/auth/session-user";
export const dynamic = "force-dynamic";
export async function GET(_req: Request) {
const session = await auth();
const id = session?.user?.id ? Number(session.user.id) : null;
if (!id || Number.isNaN(id)) {
const id = sessionUserId(session?.user?.id);
if (!id) {
return apiJson({ user: null });
}
@@ -55,6 +56,6 @@ export async function GET(_req: Request) {
},
});
} catch {
return apiJson({ user: null });
return apiUnavailable("Account data is temporarily unavailable");
}
}
+10 -8
View File
@@ -8,16 +8,17 @@
// to the user via personal_access_tokens.tokenable_id (a BigInt). NOTE: the live
// table has no expires_at column, so it is never read or written here.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sessionUserId } from "@/lib/auth/session-user";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
export const dynamic = "force-dynamic";
async function currentUserId(): Promise<number | null> {
const session = await auth();
const id = session?.user?.id ? Number(session.user.id) : null;
return id && !Number.isNaN(id) ? id : null;
return sessionUserId(session?.user?.id);
}
export async function GET(_req: Request) {
@@ -26,14 +27,14 @@ export async function GET(_req: Request) {
try {
const tokens = await prisma.personalAccessTokens.findMany({
where: { tokenableId: BigInt(id) },
where: personalTokenScope(id),
select: { id: true, name: true, lastUsedAt: true },
orderBy: { id: "desc" },
});
// Never expose the token hash.
return apiJson({ data: tokens });
} catch {
return apiJson({ data: [] });
return apiUnavailable("Token data is temporarily unavailable");
}
}
@@ -42,20 +43,21 @@ export async function DELETE(req: Request) {
if (!id) return apiError("Unauthorized", 401);
const tokenId = new URL(req.url).searchParams.get("id");
if (!tokenId || !/^\d+$/.test(tokenId)) {
const parsedTokenId = positiveBigInt(tokenId);
if (!parsedTokenId) {
return apiError("A valid token id is required", 422);
}
try {
// Scope the delete to the owner so users cannot revoke others' tokens.
const result = await prisma.personalAccessTokens.deleteMany({
where: { id: BigInt(tokenId), tokenableId: BigInt(id) },
where: { id: parsedTokenId, ...personalTokenScope(id) },
});
if (result.count === 0) {
return apiError("Token not found", 404);
}
return apiJson({ ok: true });
} catch {
return apiError("Could not revoke token", 400);
return apiUnavailable("Could not revoke token");
}
}
+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,
+5 -7
View File
@@ -1,7 +1,7 @@
// Public REST: website store packages (website_shop_articles).
// AtomCMS JSON API parity — read-only list of buyable packages, paginated and
// ordered by `position` (then name), matching the admin /admin/shop query.
import { apiJson, pagination } from "@/lib/api";
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -13,12 +13,10 @@ export async function GET(req: Request) {
// Optional ?category=<id> filter (website_shop_category_id is a BigInt).
const categoryRaw = sp.get("category");
let where: { categoryId?: bigint } = {};
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
try {
where = { categoryId: BigInt(categoryRaw) };
} catch {
where = {};
}
if (categoryRaw !== null) {
const categoryId = positiveBigInt(categoryRaw);
if (!categoryId) return apiError("A valid category id is required", 422);
where = { categoryId };
}
try {
+22 -24
View File
@@ -4,9 +4,10 @@
// into website_help_center_ticket_replies. The target ticket must exist and
// belong to the authed user. Fail-soft: never a 500.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
export const dynamic = "force-dynamic";
@@ -16,8 +17,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
if (!uid) return apiError("Unauthorized", 401);
const { id } = await params;
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
const ticketId = BigInt(id);
const ticketId = positiveBigInt(id);
if (!ticketId) return apiError("Invalid ticket id", 422);
const body = (await req.json().catch(() => ({}))) as { content?: unknown };
const content = String(body.content ?? "")
@@ -27,28 +28,25 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
try {
// Ownership check — only the ticket owner may reply.
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true },
});
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
const now = new Date();
const reply = await prisma.websiteHelpCenterTicketReplies.create({
data: {
ticketId,
userId: uid,
content,
createdAt: now,
updatedAt: now,
const reply = await prisma.$transaction((tx) => createOwnedTicketReply(
{
findTicket: (ticketId) => tx.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true },
}),
createReply: (data) => tx.websiteHelpCenterTicketReplies.create({
data,
select: { id: true, userId: true, content: true, createdAt: true },
}),
touchTicket: (ticketId, updatedAt) => tx.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { updatedAt },
select: { id: true },
}),
},
select: { id: true, userId: true, content: true, createdAt: true },
});
// Touch the parent ticket so its updatedAt reflects the latest activity.
prisma.websiteHelpCenterTickets
.update({ where: { id: ticketId }, data: { updatedAt: now }, select: { id: true } })
.catch(() => {});
{ ticketId, userId: uid, content },
));
if (!reply) return apiError("Ticket not found", 404);
return apiJson(
{
+3 -3
View File
@@ -4,7 +4,7 @@
// together with its replies; reply author usernames are resolved in a single
// users lookup. Fail-soft: never a 500.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
@@ -16,8 +16,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
if (!uid) return apiError("Unauthorized", 401);
const { id } = await params;
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
const ticketId = BigInt(id);
const ticketId = positiveBigInt(id);
if (!ticketId) return apiError("Invalid ticket id", 422);
try {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
+3 -3
View File
@@ -4,7 +4,7 @@
// Backed by website_help_center_tickets (WebsiteHelpCenterTickets). Fail-soft:
// DB errors return an apiError envelope, never a 500.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
@@ -59,8 +59,8 @@ export async function POST(req: Request) {
// value, otherwise leave it null.
let categoryId: bigint | null = null;
if (body.categoryId !== undefined && body.categoryId !== null && body.categoryId !== "") {
const raw = String(body.categoryId);
if (/^\d+$/.test(raw)) categoryId = BigInt(raw);
categoryId = positiveBigInt(String(body.categoryId));
if (!categoryId) return apiError("A valid category id is required", 422);
}
try {
+3 -2
View File
@@ -8,13 +8,14 @@
import { apiError, apiJson } from "@/lib/api";
import { issueToken } from "@/lib/api-auth";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const session = await auth();
const id = session?.user?.id ? Number(session.user.id) : null;
if (!id || Number.isNaN(id)) {
const id = sessionUserId(session?.user?.id);
if (!id) {
return apiError("Unauthorized", 401);
}
+5 -7
View File
@@ -1,7 +1,7 @@
// Public REST: rare furni trade values (website_rare_values).
// AtomCMS JSON API parity — read-only catalog of rares with their credit /
// currency values. Supports ?category=<id> filter; paginated, ordered by name.
import { apiJson, pagination } from "@/lib/api";
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -13,12 +13,10 @@ export async function GET(req: Request) {
// Optional ?category=<id> filter (category_id is a BigInt).
const categoryRaw = sp.get("category");
let where: { categoryId?: bigint } = {};
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
try {
where = { categoryId: BigInt(categoryRaw) };
} catch {
where = {};
}
if (categoryRaw !== null) {
const categoryId = positiveBigInt(categoryRaw);
if (!categoryId) return apiError("A valid category id is required", 422);
where = { categoryId };
}
try {