Add EpicNext CMS foundation layer and fix critical security gaps
Local Build and Deploy / deploy (push) Successful in 1m1s
Local Build and Deploy / deploy (push) Successful in 1m1s
- Create src/lib/foundation/ (860 LOC, 9 files): typed action wrappers, DbService with health checks, CSRF validation, safe redirects, AsyncLocalStorage request tracing, branded types, reusable Zod schemas - Migrate moderation.ts and user-settings.ts to foundation patterns - Fix abuse-guard.ts: bound in-memory Maps with LRU eviction (was unbounded) - Fix access-guard.ts: separate try/catch per check, log degradation instead of blanket fail-open - Replace raw redirect() calls with safeRedirect() in guard.ts and permissions.ts to prevent open-redirect attacks - Add CSRF validation to api-handler.ts for mutating methods - Add canonicalizeFormData() utility for FormData input sanitization
This commit is contained in:
1 parent
8bf1aa2fa7
commit
f6ad030c5b
16 files changed
+1012
-96
No files matched your search
@@ -3,8 +3,8 @@
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { adminAction, actionOk } from "@/lib/foundation/action";
|
||||
import { NotFoundError } from "@/lib/foundation/errors";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -16,7 +16,7 @@ export const assignCfhTicket = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -43,7 +43,7 @@ export const updateCfhState = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -104,7 +104,7 @@ export const quickKick = adminAction(
|
||||
|
||||
const muteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
|
||||
duration: z.coerce.number().int().min(0).max(525600),
|
||||
});
|
||||
|
||||
export const quickMute = adminAction(
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { authAction, actionOk } from "@/lib/foundation/action";
|
||||
import { DatabaseError } from "@/lib/foundation/errors";
|
||||
|
||||
// Emulator motto column is VARCHAR(127); keep the CMS-side write within bounds.
|
||||
const MOTTO_MAX = 127;
|
||||
|
||||
/**
|
||||
* Update the SIGNED-IN user's motto. The id is taken from the session
|
||||
* (re-fetched via auth()), never from the submitted FormData, so a crafted
|
||||
* form cannot mutate another account. Mirrors AtomCMS: persist + RCON setmotto
|
||||
* so an online user sees the change live.
|
||||
*/
|
||||
const mottoSchema = z.object({
|
||||
motto: z.string().max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
|
||||
});
|
||||
|
||||
const updateMottoAction = authAction(
|
||||
{ schema: mottoSchema },
|
||||
async (ctx) => {
|
||||
try {
|
||||
await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } });
|
||||
} catch {
|
||||
throw new DatabaseError("Failed to update motto");
|
||||
}
|
||||
|
||||
try {
|
||||
await rcon.setMotto(ctx.session.user.id, ctx.data.motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export async function updateMotto(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const id = Number(session.user.id);
|
||||
if (!Number.isFinite(id)) return;
|
||||
|
||||
const motto = String(formData.get("motto") ?? "").slice(0, MOTTO_MAX);
|
||||
|
||||
try {
|
||||
await prisma.user.update({ where: { id }, data: { motto } });
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rcon.setMotto(id, motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
await updateMottoAction({ motto });
|
||||
}
|
||||
|
||||
export { updateMottoAction };
|
||||
Reference in new issue
Block a user