feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m55s

Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:08:33 +02:00
1 parent 2ff08e5127
commit ed7db6e048
76 files changed
+4834 -1376

No files matched your search

+89 -44
View File
@@ -1,71 +1,116 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
const COMMENT_MAX = 255;
type CommentOutcome =
| "posted"
| "empty"
| "invalid"
| "moderated"
| "ratelimit"
| "not_found"
| "error";
function commentRedirect(slug: string, outcome: CommentOutcome): never {
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
if (outcome === "posted") redirect(`${path}?comment=posted`);
redirect(`${path}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Post a comment on a news article as the SIGNED-IN user. The author id is read
* from the session (re-fetched via auth()), never from the submitted FormData,
* so a crafted form cannot post as another account. The articleId comes from the
* form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT).
*
* Errors redirect back with a machine-readable ?error= code; success redirects
* with ?comment=posted.
*/
export async function postComment(formData: FormData): Promise<void> {
const session = await auth();
if (!session?.user?.id) return;
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
const comment = String(formData.get("comment") ?? "")
.normalize("NFC")
.trim()
.slice(0, COMMENT_MAX);
if (!comment) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
if (!(await isAllowed(comment)).ok) return;
const articleIdRaw = String(formData.get("articleId") ?? "")
const slugHint = String(formData.get("slug") ?? "")
.normalize("NFC")
.trim();
if (!/^\d+$/.test(articleIdRaw)) return;
let articleId: bigint;
let outcome: CommentOutcome = "error";
let slug = slugHint;
try {
articleId = BigInt(articleIdRaw);
} catch {
return;
}
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
let slug: string | null;
try {
// Confirm the article exists (and grab its slug for revalidation).
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) return;
slug = article.slug;
const userId = Number(session.user.id);
if (!Number.isFinite(userId) || userId <= 0) {
redirect("/login");
}
const now = new Date();
await prisma.websiteArticleComments.create({
data: {
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
await clientIp();
if (!(await rateLimit(`comment:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const comment = String(formData.get("comment") ?? "")
.normalize("NFC")
.trim()
.slice(0, COMMENT_MAX);
if (!comment) {
outcome = "empty";
} else if (!(await isAllowed(comment)).ok) {
outcome = "moderated";
} else {
const articleIdRaw = String(formData.get("articleId") ?? "")
.normalize("NFC")
.trim();
if (!/^\d+$/.test(articleIdRaw)) {
outcome = "invalid";
} else {
const articleId = BigInt(articleIdRaw);
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) {
outcome = "not_found";
} else {
slug = article.slug;
const now = new Date();
await prisma.websiteArticleComments.create({
data: {
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
},
});
outcome = "posted";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (slug) revalidatePath(`/news/${slug}`);
commentRedirect(slug, outcome);
}
+37 -5
View File
@@ -4,16 +4,25 @@ import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
export type PrecheckResult = "ok" | "invalid" | "twofactor";
export type PrecheckResult =
| "ok"
| "invalid"
| "twofactor"
| "unverified"
| "captcha";
/**
* Validates username+password WITHOUT creating a session, and reports whether a
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
* Also enforces captcha + optional email-verification when configured.
*/
export async function precheckLogin(
username: string,
password: string,
captchaToken?: string | null,
): Promise<PrecheckResult> {
const u = String(username ?? "")
.normalize("NFC")
@@ -21,14 +30,29 @@ export async function precheckLogin(
const p = String(password ?? "");
if (!u || !p) return "invalid";
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok)
return "invalid";
const ip = await clientIp();
if (!(await rateLimit(`precheck:${ip}`, 10, 5 * 60_000)).ok) return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
}
let user: {
password: string;
twoFactorConfirmedAt: Date | null;
mail: string | null;
mailVerified: string;
} | null;
try {
user = await prisma.user.findUnique({
where: { username: u },
select: { password: true, twoFactorConfirmedAt: true },
select: {
password: true,
twoFactorConfirmedAt: true,
mail: true,
mailVerified: true,
},
});
} catch {
return "invalid";
@@ -50,5 +74,13 @@ export async function precheckLogin(
});
if (!res.valid) return "invalid";
if (
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
return "unverified";
}
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
}
+203 -191
View File
@@ -1,230 +1,242 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
async function requireRcon(): Promise<void> {
await requirePermission(PERMS.RCON_EXECUTE);
const RCON_FAIL = "RCON command failed. Is the emulator running?";
async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL);
}
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export async function updateCatalog(): Promise<void> {
await requireRcon();
try {
await rcon.updateCatalog();
} catch {
// RCON is best-effort; a dead socket must not 500 the admin page.
}
revalidatePath(PATH);
}
export const updateCatalog = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateCatalog());
revalidatePath(PATH);
return actionOk();
},
);
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export async function updateWordFilter(): Promise<void> {
await requireRcon();
try {
await rcon.updateWordFilter();
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const updateWordFilter = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateWordFilter());
revalidatePath(PATH);
return actionOk();
},
);
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export async function updateNavigator(): Promise<void> {
await requireRcon();
try {
await rcon.send("updatenavigator", null);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const updateNavigator = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.send("updatenavigator", null));
revalidatePath(PATH);
return actionOk();
},
);
const hotelAlertSchema = z.object({
message: z.string().trim().min(1).max(512),
});
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export async function hotelAlert(formData: FormData): Promise<void> {
await requireRcon();
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 512);
if (!message) return;
try {
await rcon.send("hotelalert", { message });
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const hotelAlert = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.send("hotelalert", { message }));
revalidatePath(PATH);
return actionOk();
},
);
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
username: z.string().trim().min(1),
});
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
export async function disconnectUser(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
if (!userId || !username) return;
try {
await rcon.disconnectUser(userId, username);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => {
const username = ctx.data.username.normalize("NFC");
await requireRconOk(
await rcon.disconnectUser(ctx.data.userId, username),
);
revalidatePath(PATH);
return actionOk();
},
);
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().trim().min(1).max(512),
});
/** Send an alert to a specific user (rcon: alertuser). */
export async function alertUser(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 512);
if (!userId || !message) return;
try {
await rcon.alertUser(userId, message);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const alertUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
revalidatePath(PATH);
return actionOk();
},
);
const forwardUserSchema = z.object({
userId: z.coerce.number().int().positive(),
roomId: z.coerce.number().int().positive(),
});
/** Forward a user to a specific room (rcon: forwarduser). */
export async function forwardUser(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const roomId = Number(formData.get("roomId"));
if (!userId || !roomId) return;
try {
await rcon.forwardUser(userId, roomId);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const forwardUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
async (ctx) => {
await requireRconOk(
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
);
revalidatePath(PATH);
return actionOk();
},
);
const giveCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
credits: z.coerce.number().int().positive(),
});
/** Give credits to a user (rcon: givecredits). */
export async function giveCredits(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const credits = Number(formData.get("credits"));
if (!userId || !credits || credits <= 0) return;
try {
await rcon.giveCredits(userId, credits);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
);
revalidatePath(PATH);
return actionOk();
},
);
const giveAmountSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().positive(),
});
/** Give duckets to a user (rcon: givepoints type=duckets). */
export async function giveDuckets(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return;
try {
await rcon.giveDuckets(userId, amount);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
return actionOk();
},
);
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
export async function giveDiamonds(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return;
try {
await rcon.giveDiamonds(userId, amount);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
return actionOk();
},
);
const giveBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badge: z.string().trim().min(1).max(32),
});
/** Give a badge to a user (rcon: givebadge). */
export async function giveBadge(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.trim();
if (!userId || !badge) return;
try {
await rcon.giveBadge(userId, badge);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const giveBadge = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
async (ctx) => {
const badge = ctx.data.badge.normalize("NFC");
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
revalidatePath(PATH);
return actionOk();
},
);
const setMottoSchema = z.object({
userId: z.coerce.number().int().positive(),
motto: z.string().trim().min(1).max(127),
});
/** Set a user's motto (rcon: setmotto). */
export async function setMotto(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const motto = String(formData.get("motto") ?? "")
.normalize("NFC")
.trim()
.slice(0, 127);
if (!userId || !motto) return;
try {
await rcon.setMotto(userId, motto);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const setMotto = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
async (ctx) => {
const motto = ctx.data.motto.normalize("NFC");
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
revalidatePath(PATH);
return actionOk();
},
);
const setRankSchema = z.object({
userId: z.coerce.number().int().positive(),
rank: z.coerce.number().int().min(0).max(10),
});
/** Set a user's rank (rcon: setrank). */
export async function setRank(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const rank = Number(formData.get("rank"));
if (!userId || rank < 0 || rank > 10) return;
try {
await rcon.setRank(userId, rank);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => {
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
revalidatePath(PATH);
return actionOk();
},
);
const executeCommandSchema = z.object({
userId: z.coerce.number().int().positive(),
command: z.string().trim().min(1).max(100),
});
/** Execute a command as a user (rcon: executecommand). */
export async function executeCommand(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const command = String(formData.get("command") ?? "")
.normalize("NFC")
.trim();
if (!userId || !command) return;
try {
await rcon.executeCommand(userId, command);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => {
const command = ctx.data.command.normalize("NFC");
await requireRconOk(
await rcon.executeCommand(ctx.data.userId, command),
);
revalidatePath(PATH);
return actionOk();
},
);
const sendGiftSchema = z.object({
userId: z.coerce.number().int().positive(),
itemId: z.coerce.number().int().positive(),
message: z.string().trim().max(255).optional().default("Here is a gift."),
});
/** Send a gift to a user (rcon: sendgift). */
export async function sendGift(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const itemId = Number(formData.get("itemId"));
const message = String(formData.get("message") ?? "Here is a gift.")
.trim()
.slice(0, 255);
if (!userId || !itemId) return;
try {
await rcon.sendGift(userId, itemId, message);
} catch {
// best-effort
}
revalidatePath(PATH);
}
export const sendGift = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
async (ctx) => {
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
await requireRconOk(
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
);
revalidatePath(PATH);
return actionOk();
},
);
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockGet = vi.hoisted(() => vi.fn());
const mockSendMail = vi.hoisted(() => vi.fn());
const mockGetTranslations = vi.hoisted(() => vi.fn());
vi.mock("@/env", () => ({
env: {
APP_KEY: "test-app-key-for-hmac",
AUTH_SECRET: "",
APP_URL: "http://localhost:3000",
HOTEL_NAME: "TestHotel",
},
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: mockGet },
}));
vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail,
}));
vi.mock("next-intl/server", () => ({
getTranslations: mockGetTranslations,
}));
import {
isValidVerificationToken,
sendVerification,
verificationToken,
} from "./email-verify";
beforeEach(() => {
vi.clearAllMocks();
mockGet.mockResolvedValue("TestHotel");
mockSendMail.mockResolvedValue(true);
mockGetTranslations.mockRejectedValue(new Error("missing"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("email verification tokens", () => {
it("issues timestamped HMAC tokens that validate", async () => {
const token = await verificationToken("[email protected]");
expect(token).toMatch(/^\d+\.[a-f0-9]{64}$/);
expect(await isValidVerificationToken("[email protected]", token)).toBe(
true,
);
});
it("rejects legacy forever-valid digests", async () => {
const legacy = "a".repeat(64);
expect(
await isValidVerificationToken("[email protected]", legacy),
).toBe(false);
});
it("rejects expired tokens", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const token = await verificationToken("[email protected]");
vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); // > 24h
expect(await isValidVerificationToken("[email protected]", token)).toBe(
false,
);
});
it("sends mail with a verify link", async () => {
mockGetTranslations.mockResolvedValue(
((key: string, values?: { hotel?: string }) => {
const map: Record<string, string> = {
subject: `Verify your email · ${values?.hotel}`,
heading: "Verify your email",
body: `Welcome to ${values?.hotel}!`,
button: "Verify email",
fallback: "Paste this link:",
};
return map[key] ?? key;
}) as never,
);
await sendVerification("[email protected]");
expect(mockSendMail).toHaveBeenCalledWith(
"[email protected]",
expect.stringContaining("Verify your email"),
expect.stringContaining("/verify?token="),
);
});
});
+60 -24
View File
@@ -1,20 +1,21 @@
"use server";
import { createHash, timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
import { getTranslations } from "next-intl/server";
import { env } from "@/env";
import { sendMail } from "@/lib/services/email";
import { siteSettings } from "@/lib/services/site-settings";
// Stateless email verification, AtomCMS-faithful but DB-table-free.
// Stateless email verification with a time-limited HMAC token.
//
// Instead of persisting a row (password_resets style), the token is a keyed
// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a
// server-only secret, an attacker who only knows the email cannot forge a
// matching token, and /verify can recompute + compare it without any storage.
// The token is therefore deterministic per (email, secret) pair and stays valid
// until the account's mail_verified flips to '1' (after which /verify no-ops).
// Token format: `{issuedAtUnix}.{hmacHex}` where
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
// (bare 64-char hex) are rejected.
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret)
@@ -24,26 +25,43 @@ function verifySecret(): string {
return secret;
}
/** Compute the verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
return createHash("sha256")
.update(`${normalised}|${verifySecret()}`)
function sign(email: string, issuedAt: number): string {
return createHmac("sha256", verifySecret())
.update(`${email}|${issuedAt}`)
.digest("hex");
}
/** Compute a fresh verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
const issuedAt = Math.floor(Date.now() / 1000);
return `${issuedAt}.${sign(normalised, issuedAt)}`;
}
/**
* Constant-time check that `token` matches the expected digest for `email`.
* Returns false on any length/format mismatch rather than throwing.
* Constant-time check that `token` matches a non-expired HMAC for `email`.
* Returns false on format/expiry/signature mismatch rather than throwing.
*/
export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
const expected = await verificationToken(email);
if (!email || !token) return false;
const normalised = email.trim().toLowerCase();
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
if (!match) return false; // also rejects legacy forever-valid digests
const issuedAt = Number(match[1]);
const sig = match[2]?.toLowerCase() ?? "";
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
const ageMs = Date.now() - issuedAt * 1000;
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
const expected = sign(normalised, issuedAt);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(token.toLowerCase(), "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
@@ -63,22 +81,40 @@ export async function sendVerification(email: string): Promise<boolean> {
const hotelName =
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
let subject = `Verify your email · ${hotelName}`;
let heading = "Verify your email";
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
let button = "Verify email";
let fallback =
"If the button doesn't work, paste this link into your browser:";
try {
const t = await getTranslations("emails.verify");
subject = t("subject", { hotel: hotelName });
heading = t("heading");
body = t("body", { hotel: hotelName });
button = t("button");
fallback = t("fallback");
} catch {
/* messages missing — keep English defaults */
}
const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
<p>${escapeHtml(body)}</p>
<p style="margin:1.25rem 0">
<a href="${link}"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
Verify email
${escapeHtml(button)}
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
</div>
`.trim();
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
return sendMail(normalised, subject, html);
}
function escapeHtml(s: string): string {
+64 -2
View File
@@ -1,16 +1,18 @@
"use server";
import { revalidatePath } from "next/cache";
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, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createEventSchema,
eventPrizeSchema,
eventTypeSchema,
eventWinnerSchema,
registerForEventSchema,
updateEventSchema,
} from "@/lib/validators/event";
@@ -193,3 +195,63 @@ export const addEventWinner = adminAction(
return actionOk({ id: winner.id });
},
);
// ── Public site: register ───────────────────────────────────────────
export const registerForEvent = authAction(
{
schema: registerForEventSchema,
rateLimitKey: "event-register",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const userId = Number(ctx.session.user.id);
if (!Number.isInteger(userId) || userId <= 0) {
return actionError("Unauthorized");
}
const event = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.eventId },
include: {
type: true,
_count: { select: { registrations: true } },
},
});
if (!event) return actionError("Event not found");
if (event.status !== "published") {
return actionError("This event is not open for registration");
}
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
return actionError("This event has already ended");
}
if (event.type.minRank > 0) {
const rank = Number(ctx.session.user.rank ?? 0);
if (rank < event.type.minRank) {
return actionError("Your rank is too low to join this event");
}
}
if (
event.maxPlayers != null &&
event._count.registrations >= event.maxPlayers
) {
return actionError("This event is full");
}
const existing = await prisma.websiteEventRegistration.findUnique({
where: {
eventId_userId: { eventId: event.id, userId },
},
});
if (existing) return actionError("You are already registered");
await prisma.websiteEventRegistration.create({
data: { eventId: event.id, userId },
});
revalidatePath("/events");
revalidatePath(`/events/${event.id}`);
return actionOk({ eventId: event.id });
},
);
+73 -31
View File
@@ -1,13 +1,39 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
const MESSAGE_MAX = 255;
type GuestbookOutcome =
| "posted"
| "empty"
| "invalid"
| "moderated"
| "ratelimit"
| "error";
function guestbookRedirect(username: string, outcome: GuestbookOutcome): never {
const path = username ? `/u/${encodeURIComponent(username)}` : "/";
if (outcome === "posted") redirect(`${path}?guestbook=posted`);
redirect(`${path}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Post a guestbook entry on a profile.
*
@@ -15,45 +41,61 @@ const MESSAGE_MAX = 255;
* trusted from the submitted FormData, so a crafted form cannot impersonate
* another account. Only the PROFILE OWNER id (whose guestbook is written) is
* taken from the form, and we resolve a profile username from the form purely
* to revalidate the right page.
* to revalidate / redirect to the right page.
*
* Errors redirect back with a machine-readable ?error= code; success redirects
* with ?guestbook=posted.
*/
export async function postGuestbook(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) return;
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
if (!(await isAllowed(message)).ok) return;
// Optional: used only to revalidate the correct profile route.
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
const now = new Date();
let outcome: GuestbookOutcome = "error";
try {
await prisma.websiteUserGuestbooks.create({
data: {
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`guestbook:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) {
outcome = "invalid";
} else {
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) {
outcome = "empty";
} else if (!(await isAllowed(message)).ok) {
outcome = "moderated";
} else {
const now = new Date();
await prisma.websiteUserGuestbooks.create({
data: {
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
},
});
outcome = "posted";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (username) revalidatePath(`/u/${username}`);
guestbookRedirect(username, outcome);
}
+80 -39
View File
@@ -1,6 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@@ -12,49 +13,89 @@ const ticketSchema = z.object({
content: z.string().min(1, "Content is required").max(5000),
});
type TicketOutcome =
| "created"
| "invalid"
| "moderated"
| "ratelimit"
| "error";
function ticketsRedirect(outcome: TicketOutcome): never {
if (outcome === "created") redirect("/help/tickets?created=1");
redirect(`/help/tickets?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
export async function createTicket(formData: FormData): Promise<void> {
// Re-read the session user id server-side; never trust a form-supplied id.
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
let outcome: TicketOutcome = "error";
await clientIp();
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
const raw = {
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
content: String(formData.get("content") ?? "")
.normalize("NFC")
.trim()
.slice(0, 5000),
};
const parsed = ticketSchema.safeParse(raw);
if (!parsed.success) return;
const { title, content } = parsed.data;
// Moderation check
try {
await moderateOrThrow(`${title} ${content}`);
} catch {
return;
// Re-read the session user id server-side; never trust a form-supplied id.
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) {
outcome = "ratelimit";
} else {
const raw = {
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
content: String(formData.get("content") ?? "")
.normalize("NFC")
.trim()
.slice(0, 5000),
};
const parsed = ticketSchema.safeParse(raw);
if (!parsed.success) {
outcome = "invalid";
} else {
const { title, content } = parsed.data;
let moderated = false;
try {
await moderateOrThrow(`${title} ${content}`);
} catch {
moderated = true;
outcome = "moderated";
}
if (!moderated) {
const now = new Date();
await prisma.websiteHelpCenterTickets.create({
data: {
userId,
title,
content,
open: true,
createdAt: now,
updatedAt: now,
},
});
outcome = "created";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
const now = new Date();
await prisma.websiteHelpCenterTickets.create({
data: {
userId,
title,
content,
open: true,
createdAt: now,
updatedAt: now,
},
});
revalidatePath("/help/tickets");
ticketsRedirect(outcome);
}
+217 -43
View File
@@ -1,9 +1,30 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
type FriendOutcome =
| "accepted"
| "declined"
| "removed"
| "not_found"
| "unauthorized"
| "invalid"
| "error";
function messagesRedirect(outcome: FriendOutcome): never {
if (outcome === "accepted") redirect("/messages?accepted=1");
if (outcome === "declined") redirect("/messages?declined=1");
redirect(`/messages?error=${outcome}`);
}
function friendsRedirect(outcome: FriendOutcome): never {
if (outcome === "removed") redirect("/friends?removed=1");
redirect(`/friends?error=${outcome}`);
}
/**
* Accept a pending friend request as the SIGNED-IN user.
*
@@ -16,60 +37,213 @@ import { prisma } from "@/lib/prisma";
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
* create both inside a transaction and delete the originating request so it no
* longer shows as pending in the in-game messenger or here.
*
* Errors redirect back to /messages with a machine-readable ?error= code;
* success redirects with ?accepted=1. redirect() is called OUTSIDE the
* try/catch so its control-flow throw is never swallowed.
*/
export async function acceptFriend(formData: FormData): Promise<void> {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) return;
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) return;
let outcome: FriendOutcome = "error";
try {
// The request must exist AND be addressed to the session user.
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userFromId: true, userToId: true },
});
if (!request || request.userToId !== meId) return;
const friendId = request.userFromId;
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
// Malformed/self request — just clear it.
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
return;
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
const friendsSince = Math.floor(Date.now() / 1000);
await prisma.$transaction(async (tx) => {
// Don't double-insert if a friendship already exists in either direction.
const existing = await tx.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
select: { id: true },
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid";
} else {
// The request must exist AND be addressed to the session user.
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userFromId: true, userToId: true },
});
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
outcome = "unauthorized";
} else {
const friendId = request.userFromId;
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
// Malformed/self request — clear it and treat as not found.
await prisma.messengerFriendrequests.delete({
where: { id: requestId },
});
outcome = "not_found";
} else {
const friendsSince = Math.floor(Date.now() / 1000);
if (!existing) {
await tx.messengerFriendships.createMany({
data: [
{ userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince },
],
});
await prisma.$transaction(async (tx) => {
// Don't double-insert if a friendship already exists in either direction.
const existing = await tx.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
select: { id: true },
});
if (!existing) {
await tx.messengerFriendships.createMany({
data: [
{ userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince },
],
});
}
// Clear this request and any reverse pending request between the pair.
await tx.messengerFriendrequests.deleteMany({
where: {
OR: [
{ id: requestId },
{ userFromId: meId, userToId: friendId },
{ userFromId: friendId, userToId: meId },
],
},
});
});
outcome = "accepted";
}
}
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
} catch (e) {
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
revalidatePath("/friends");
messagesRedirect(outcome);
}
/**
* Decline a pending friend request as the SIGNED-IN user.
*
* Only the request's target (user_to_id) may decline. Deletes the
* messenger_friendrequests row without creating a friendship.
*/
export async function declineFriendRequest(formData: FormData): Promise<void> {
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid";
} else {
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userToId: true },
});
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
outcome = "unauthorized";
} else {
await prisma.messengerFriendrequests.delete({
where: { id: requestId },
});
outcome = "declined";
}
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
messagesRedirect(outcome);
}
/**
* Remove an existing friendship between the SIGNED-IN user and another user.
*
* Deletes BOTH directional rows in messenger_friendships (Arcturus stores one
* row each way) and clears any leftover pending requests between the pair.
* Only the friend id comes from the form; the session user is never trusted
* from FormData.
*/
export async function removeFriendship(formData: FormData): Promise<void> {
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
const friendId = Number(formData.get("friendId"));
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
outcome = "invalid";
} else {
const deleted = await prisma.$transaction(async (tx) => {
const result = await tx.messengerFriendships.deleteMany({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
});
await tx.messengerFriendrequests.deleteMany({
where: {
OR: [
{ userFromId: meId, userToId: friendId },
{ userFromId: friendId, userToId: meId },
],
},
});
return result.count;
});
outcome = deleted > 0 ? "removed" : "not_found";
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/friends");
revalidatePath("/messages");
friendsRedirect(outcome);
}
+9
View File
@@ -40,6 +40,15 @@ vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail,
}));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: vi.fn().mockResolvedValue({
provider: "none",
siteKey: "",
field: "",
}),
verifyCaptcha: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/rate-limit", () => ({
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
+13 -2
View File
@@ -6,6 +6,7 @@ import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { sendMail } from "@/lib/services/email";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -20,9 +21,19 @@ export async function requestReset(formData: FormData): Promise<void> {
.trim()
.toLowerCase();
const ip = await clientIp();
// CAPTCHA when a provider is configured (mirrors register).
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip))) {
redirect("/forgot?error=captcha");
}
}
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000))
.ok;
const allowed = (await rateLimit(`reset:${ip}`, 3, 15 * 60_000)).ok;
// Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
+113 -2
View File
@@ -1,15 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
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, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createPollSchema,
pollQuestionSchema,
updatePollSchema,
voteOnPollSchema,
} from "@/lib/validators/poll";
// ── Polls ───────────────────────────────────────────────────────────
@@ -113,3 +115,112 @@ export const deletePollQuestion = adminAction(
return actionOk();
},
);
// ── Public site: vote ───────────────────────────────────────────────
function parsePollOptions(options: string): string[] {
return options
.split("\n")
.map((o) => o.trim())
.filter(Boolean);
}
export const voteOnPoll = authAction(
{
schema: voteOnPollSchema,
rateLimitKey: "poll-vote",
rateLimitMax: 20,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const userId = Number(ctx.session.user.id);
if (!Number.isInteger(userId) || userId <= 0) {
return actionError("Unauthorized");
}
const poll = await prisma.websitePoll.findUnique({
where: { id: ctx.data.pollId },
include: { questions: true },
});
if (!poll) return actionError("Poll not found");
if (poll.status !== "active") {
return actionError("This poll is not open for voting");
}
const now = Date.now();
if (poll.startsAt && poll.startsAt.getTime() > now) {
return actionError("This poll has not started yet");
}
if (poll.endsAt && poll.endsAt.getTime() < now) {
return actionError("This poll has ended");
}
const questionById = new Map(poll.questions.map((q) => [q.id, q]));
const seen = new Set<number>();
for (const vote of ctx.data.votes) {
if (seen.has(vote.questionId)) {
return actionError("Duplicate vote for the same question");
}
seen.add(vote.questionId);
const question = questionById.get(vote.questionId);
if (!question || question.pollId !== poll.id) {
return actionError("Invalid question for this poll");
}
const answer = vote.answer.trim();
if (!answer) return actionError("Answer is required");
if (question.type === "text") {
if (answer.length > 500) {
return actionError("Answer is too long");
}
} else {
const options = parsePollOptions(question.options);
if (question.type === "multiple") {
const selected = answer
.split("\n")
.map((a) => a.trim())
.filter(Boolean);
if (selected.length === 0) {
return actionError("Select at least one option");
}
if (selected.some((a) => !options.includes(a))) {
return actionError("Invalid option selected");
}
} else if (!options.includes(answer)) {
return actionError("Invalid option selected");
}
}
const existing = await prisma.websitePollVote.findUnique({
where: {
questionId_userId: {
questionId: vote.questionId,
userId,
},
},
});
if (existing) {
return actionError("You have already voted on this poll");
}
}
await prisma.$transaction(
ctx.data.votes.map((vote) =>
prisma.websitePollVote.create({
data: {
questionId: vote.questionId,
userId,
answer: vote.answer.trim(),
},
}),
),
);
revalidatePath("/polls");
revalidatePath(`/polls/${poll.id}`);
return actionOk({ pollId: poll.id });
},
);
+75 -36
View File
@@ -1,6 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@@ -11,6 +12,28 @@ const shoutSchema = z.object({
message: z.string().min(1, "Message is required").max(255),
});
type ShoutOutcome =
| "posted"
| "invalid"
| "moderated"
| "ratelimit"
| "error";
function shoutsRedirect(outcome: ShoutOutcome): never {
if (outcome === "posted") redirect("/radio/shouts?posted=1");
redirect(`/radio/shouts?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Post a radio shout.
*
@@ -18,48 +41,64 @@ const shoutSchema = z.object({
* trusted from the submitted FormData, so a crafted form cannot impersonate
* another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int
* session id is widened to BigInt for the insert.
*
* Errors redirect back with a machine-readable ?error= code; success redirects
* with ?posted=1.
*/
export async function postShout(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
let outcome: ShoutOutcome = "error";
await clientIp();
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
const raw = {
message: String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
};
const parsed = shoutSchema.safeParse(raw);
if (!parsed.success) return;
const { message } = parsed.data;
// Moderation check
try {
await moderateOrThrow(message);
} catch {
return;
}
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
const now = new Date();
try {
await prisma.radioShouts.create({
data: {
userId: BigInt(userId),
message,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
await clientIp();
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const raw = {
message: String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
};
const parsed = shoutSchema.safeParse(raw);
if (!parsed.success) {
outcome = "invalid";
} else {
const { message } = parsed.data;
let moderated = false;
try {
await moderateOrThrow(message);
} catch {
moderated = true;
outcome = "moderated";
}
if (!moderated) {
const now = new Date();
await prisma.radioShouts.create({
data: {
userId: BigInt(userId),
message,
createdAt: now,
updatedAt: now,
},
});
outcome = "posted";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/radio/shouts");
shoutsRedirect(outcome);
}
+191 -94
View File
@@ -1,8 +1,10 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
// guilds_forums_comments.message which is TEXT. Keep the first post's message
@@ -10,6 +12,51 @@ import { prisma } from "@/lib/prisma";
const SUBJECT_MAX = 255;
const MESSAGE_MAX = 10000;
type FriendRequestOutcome =
| "sent"
| "self"
| "invalid"
| "already_friends"
| "already_pending"
| "incoming_pending"
| "ratelimit"
| "error";
type ThreadOutcome =
| "posted"
| "invalid"
| "not_found"
| "ratelimit"
| "error";
function profileRedirect(
username: string,
outcome: FriendRequestOutcome,
): never {
const path = username ? `/u/${encodeURIComponent(username)}` : "/";
if (outcome === "sent") redirect(`${path}?friend=sent`);
redirect(`${path}?error=${outcome}`);
}
function threadRedirect(guildId: number, outcome: ThreadOutcome): never {
const base =
Number.isInteger(guildId) && guildId > 0
? `/guilds/${guildId}/forum`
: "/guilds";
if (outcome === "posted") redirect(`${base}?posted=1`);
redirect(`${base}/new?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Send a friend request to another user.
*
@@ -19,52 +66,81 @@ const MESSAGE_MAX = 10000;
*
* Writes into messenger_friendrequests (userFromId = requester, userToId =
* target). The emulator surfaces the pending request in the in-game messenger.
*
* Errors redirect back to the profile with a machine-readable ?error= code;
* success redirects with ?friend=sent. redirect() is called OUTSIDE the
* try/catch so its control-flow throw is never swallowed.
*/
export async function sendFriendRequest(formData: FormData): Promise<void> {
const session = await auth();
const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) return;
const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) return;
// Can't befriend yourself.
if (toId === fromId) return;
try {
// Guard against duplicate pending requests and already-existing friendships.
const [existingRequest, existingFriendship] = await Promise.all([
prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId },
select: { id: true },
}),
prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: fromId, userTwoId: toId },
{ userOneId: toId, userTwoId: fromId },
],
},
select: { id: true },
}),
]);
if (existingRequest || existingFriendship) return;
await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId },
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
// Optional: revalidate the target profile if a username was supplied, purely
// to refresh any request-state UI rendered there.
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
let outcome: FriendRequestOutcome = "error";
try {
const session = await auth();
const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend:${fromId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) {
outcome = "invalid";
} else if (toId === fromId) {
outcome = "self";
} else {
// Guard against duplicate pending requests and already-existing friendships.
const [outgoingRequest, incomingRequest, existingFriendship] =
await Promise.all([
prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId },
select: { id: true },
}),
prisma.messengerFriendrequests.findFirst({
where: { userFromId: toId, userToId: fromId },
select: { id: true },
}),
prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: fromId, userTwoId: toId },
{ userOneId: toId, userTwoId: fromId },
],
},
select: { id: true },
}),
]);
if (existingFriendship) {
outcome = "already_friends";
} else if (outgoingRequest) {
outcome = "already_pending";
} else if (incomingRequest) {
// They already asked you — respond from Messages instead of
// creating a duplicate reverse row.
outcome = "incoming_pending";
} else {
await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId },
});
outcome = "sent";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (username) revalidatePath(`/u/${username}`);
revalidatePath("/messages");
profileRedirect(username, outcome);
}
/**
@@ -78,67 +154,88 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
* plus the opening post stored as the first comment (guilds_forums_comments).
* We create both in a transaction so the thread always has its first post, then
* stamp posts_count = 1 to match the emulator's bookkeeping.
*
* Errors redirect back to the new-thread form with ?error=; success redirects
* to the forum with ?posted=1.
*/
export async function postThread(formData: FormData): Promise<void> {
const session = await auth();
const openerId = Number(session?.user?.id);
if (!Number.isInteger(openerId) || openerId <= 0) return;
const guildId = Number(formData.get("guildId"));
if (!Number.isInteger(guildId) || guildId <= 0) return;
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, SUBJECT_MAX);
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!subject || !message) return;
const now = Math.floor(Date.now() / 1000);
let outcome: ThreadOutcome = "error";
try {
// Confirm the guild exists (and has a forum) before opening a thread.
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true },
});
if (!guild) return;
const session = await auth();
const openerId = Number(session?.user?.id);
if (!Number.isInteger(openerId) || openerId <= 0) {
redirect("/login");
}
await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({
data: {
guildId,
openerId,
subject,
postsCount: 1,
createdAt: now,
updatedAt: now,
state: 0,
pinned: 0,
locked: 0,
adminId: 0,
},
select: { id: true },
});
if (!Number.isInteger(guildId) || guildId <= 0) {
outcome = "invalid";
} else {
await clientIp();
if (!(await rateLimit(`forum:${openerId}`, 3, 60_000)).ok) {
outcome = "ratelimit";
} else {
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, SUBJECT_MAX);
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!subject || !message) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId: openerId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
});
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true },
});
if (!guild) {
outcome = "not_found";
} else {
await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({
data: {
guildId,
openerId,
subject,
postsCount: 1,
createdAt: now,
updatedAt: now,
state: 0,
pinned: 0,
locked: 0,
adminId: 0,
},
select: { id: true },
});
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId: openerId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
});
});
outcome = "posted";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath(`/guilds/${guildId}/forum`);
if (Number.isInteger(guildId) && guildId > 0) {
revalidatePath(`/guilds/${guildId}/forum`);
}
threadRedirect(guildId, outcome);
}