diff --git a/src/actions/article-comments.ts b/src/actions/article-comments.ts index 6605e37e6a..1c0c5d66ae 100644 --- a/src/actions/article-comments.ts +++ b/src/actions/article-comments.ts @@ -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 { - 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); } diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts index 7ef4154d8c..f60a00a089 100644 --- a/src/actions/auth-precheck.ts +++ b/src/actions/auth-precheck.ts @@ -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 { 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"; } diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index 5c3576e717..acfee12450 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -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 { - await requirePermission(PERMS.RCON_EXECUTE); +const RCON_FAIL = "RCON command failed. Is the emulator running?"; + +async function requireRconOk(ok: boolean): Promise { + if (!ok) throw new ActionError(RCON_FAIL); } /** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */ -export async function updateCatalog(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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(); + }, +); diff --git a/src/actions/email-verify.test.ts b/src/actions/email-verify.test.ts new file mode 100644 index 0000000000..1ad3ea87b0 --- /dev/null +++ b/src/actions/email-verify.test.ts @@ -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("User@Example.com"); + expect(token).toMatch(/^\d+\.[a-f0-9]{64}$/); + expect(await isValidVerificationToken("user@example.com", token)).toBe( + true, + ); + }); + + it("rejects legacy forever-valid digests", async () => { + const legacy = "a".repeat(64); + expect( + await isValidVerificationToken("user@example.com", legacy), + ).toBe(false); + }); + + it("rejects expired tokens", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const token = await verificationToken("user@example.com"); + vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); // > 24h + expect(await isValidVerificationToken("user@example.com", token)).toBe( + false, + ); + }); + + it("sends mail with a verify link", async () => { + mockGetTranslations.mockResolvedValue( + ((key: string, values?: { hotel?: string }) => { + const map: Record = { + 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("user@example.com"); + expect(mockSendMail).toHaveBeenCalledWith( + "user@example.com", + expect.stringContaining("Verify your email"), + expect.stringContaining("/verify?token="), + ); + }); +}); diff --git a/src/actions/email-verify.ts b/src/actions/email-verify.ts index 83c81b41ce..9cb351bb2e 100644 --- a/src/actions/email-verify.ts +++ b/src/actions/email-verify.ts @@ -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 { - 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 { + 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 { - 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 { 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 = `
-

Verify your email

-

Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.

+

${escapeHtml(heading)}

+

${escapeHtml(body)}

- Verify email + ${escapeHtml(button)}

-

If the button doesn't work, paste this link into your browser:

+

${escapeHtml(fallback)}

${link}

`.trim(); - return sendMail(normalised, `Verify your email · ${hotelName}`, html); + return sendMail(normalised, subject, html); } function escapeHtml(s: string): string { diff --git a/src/actions/events.ts b/src/actions/events.ts index 3a831c1268..95789fba2c 100644 --- a/src/actions/events.ts +++ b/src/actions/events.ts @@ -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 }); + }, +); diff --git a/src/actions/guestbook.ts b/src/actions/guestbook.ts index 2863699323..8ddce867b6 100644 --- a/src/actions/guestbook.ts +++ b/src/actions/guestbook.ts @@ -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 { - 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); } diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index bcc00d57a4..fc6a9ab13c 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -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 { - // 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); } diff --git a/src/actions/messenger.ts b/src/actions/messenger.ts index 87b8b7a456..8070c79874 100644 --- a/src/actions/messenger.ts +++ b/src/actions/messenger.ts @@ -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 { - 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 { + 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 { + 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); } diff --git a/src/actions/password-reset.test.ts b/src/actions/password-reset.test.ts index 8b6159ae26..1f2c4fbfc9 100644 --- a/src/actions/password-reset.test.ts +++ b/src/actions/password-reset.test.ts @@ -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"), diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts index 4dbf4db667..393839e121 100644 --- a/src/actions/password-reset.ts +++ b/src/actions/password-reset.ts @@ -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 { .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)) { diff --git a/src/actions/polls.ts b/src/actions/polls.ts index 42dafd7388..d9da76577d 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -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(); + + 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 }); + }, +); diff --git a/src/actions/radio-shouts.ts b/src/actions/radio-shouts.ts index bde86d558f..77da5cc46a 100644 --- a/src/actions/radio-shouts.ts +++ b/src/actions/radio-shouts.ts @@ -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 { - 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); } diff --git a/src/actions/social.ts b/src/actions/social.ts index 87bc63ba7c..4922e947aa 100644 --- a/src/actions/social.ts +++ b/src/actions/social.ts @@ -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 { - 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 { * 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 { - 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); } diff --git a/src/app/(site)/error.tsx b/src/app/(site)/error.tsx new file mode 100644 index 0000000000..57e4b41c32 --- /dev/null +++ b/src/app/(site)/error.tsx @@ -0,0 +1,59 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { Home, RefreshCw } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useEffect } from "react"; +import { ErrorScreen } from "@/components/error-screen"; + +/** + * Site route-segment error boundary. Renders inside the site shell layout. + * Never shows the raw error to the user. + */ +export default function SiteErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations("pages.error"); + + useEffect(() => { + console.error(error); + Sentry.captureException(error); + }, [error]); + + return ( + + + + + {t("backHome")} + + + } + footer={ + error.digest ? ( +

+ {t("reference", { digest: error.digest })} +

+ ) : null + } + /> + ); +} diff --git a/src/app/(site)/events/[slugOrId]/event-register-button.tsx b/src/app/(site)/events/[slugOrId]/event-register-button.tsx new file mode 100644 index 0000000000..627cb5e533 --- /dev/null +++ b/src/app/(site)/events/[slugOrId]/event-register-button.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { registerForEvent } from "@/actions/events"; +import { useServerAction } from "@/hooks/use-server-action"; + +export function EventRegisterButton({ + eventId, + disabled, + disabledReason, +}: { + eventId: number; + disabled?: boolean; + disabledReason?: string; +}) { + const t = useTranslations("pages.events"); + const { run, isPending } = useServerAction(); + + if (disabled) { + return ( +

+ {disabledReason ?? t("cannotRegister")} +

+ ); + } + + return ( + + ); +} diff --git a/src/app/(site)/events/[slugOrId]/page.tsx b/src/app/(site)/events/[slugOrId]/page.tsx new file mode 100644 index 0000000000..3087ea0953 --- /dev/null +++ b/src/app/(site)/events/[slugOrId]/page.tsx @@ -0,0 +1,203 @@ +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { ContentCard, EmptyState } from "@/components/public/ui"; +import { auth } from "@/lib/auth"; +import { formatDate } from "@/lib/format-date"; +import { prisma } from "@/lib/prisma"; +import { EventRegisterButton } from "./event-register-button"; + +export const dynamic = "force-dynamic"; + +function parseEventId(slugOrId: string): number | null { + if (/^\d+$/.test(slugOrId)) return Number.parseInt(slugOrId, 10); + const match = slugOrId.match(/-(\d+)$/); + if (match) return Number.parseInt(match[1], 10); + return null; +} + +export default async function EventDetailPage({ + params, +}: { + params: Promise<{ slugOrId: string }>; +}) { + const { slugOrId } = await params; + const eventId = parseEventId(slugOrId); + if (eventId == null || !Number.isFinite(eventId)) notFound(); + + const t = await getTranslations("pages.events"); + const session = await auth(); + const userId = Number(session?.user?.id); + + const event = await prisma.websiteEvent + .findUnique({ + where: { id: eventId }, + include: { + type: true, + prizes: { orderBy: { position: "asc" } }, + winners: { orderBy: { position: "asc" } }, + _count: { select: { registrations: true } }, + }, + }) + .catch(() => null); + + if (!event || (event.status !== "published" && event.status !== "completed")) { + notFound(); + } + + const alreadyRegistered = + Number.isInteger(userId) && userId > 0 + ? Boolean( + await prisma.websiteEventRegistration + .findUnique({ + where: { eventId_userId: { eventId: event.id, userId } }, + }) + .catch(() => null), + ) + : false; + + const winnerIds = event.winners.map((w) => w.userId); + const winners = + winnerIds.length > 0 + ? await prisma.user + .findMany({ + where: { id: { in: winnerIds } }, + select: { id: true, username: true }, + }) + .catch(() => []) + : []; + const winnerName = new Map(winners.map((u) => [u.id, u.username])); + + const isFull = + event.maxPlayers != null && + event._count.registrations >= event.maxPlayers; + const hasEnded = + event.status === "completed" || + (event.endsAt != null && event.endsAt.getTime() < Date.now()); + const canRegister = + event.status === "published" && !hasEnded && !isFull && !alreadyRegistered; + + let disabledReason: string | undefined; + if (!session?.user) disabledReason = t("loginToRegister"); + else if (alreadyRegistered) disabledReason = t("alreadyRegistered"); + else if (hasEnded) disabledReason = t("eventEnded"); + else if (isFull) disabledReason = t("eventFull"); + + return ( +
+

+ {t("back")} +

+ + {event.image ? ( +
+ +
+ ) : null} + + +
+

+ {event.description} +

+ + + + + + + + + + + + + + + +
{t("when")} + {formatDate(event.startsAt, "datetime", "")} + {event.endsAt + ? ` — ${formatDate(event.endsAt, "datetime", "")}` + : ""} +
{t("status")} + {event.status === "completed" + ? t("statusCompleted") + : t("statusPublished")} +
{t("registrations")} + {event.maxPlayers != null + ? t("spots", { + count: event._count.registrations, + max: event.maxPlayers, + }) + : t("registered", { + count: event._count.registrations, + })} +
+
+
+ + + {session?.user && canRegister ? ( + + ) : ( + + {disabledReason ?? t("cannotRegister")} + + )} + + + {event.prizes.length > 0 ? ( + +
    + {event.prizes.map((prize) => ( +
  • + #{prize.position} + {prize.description + ? ` — ${prize.description}` + : ` — ${prize.prizeType}`} + {prize.credits > 0 ? ` · ${prize.credits} credits` : ""} + {prize.pixels > 0 ? ` · ${prize.pixels} pixels` : ""} + {prize.points > 0 ? ` · ${prize.points} points` : ""} + {prize.badgeCode ? ` · badge ${prize.badgeCode}` : ""} +
  • + ))} +
+
+ ) : null} + + {event.winners.length > 0 ? ( + +
    + {event.winners.map((w) => ( +
  • + #{w.position} —{" "} + {winnerName.get(w.userId) ?? t("unknownUser", { id: w.userId })} +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/app/(site)/events/page.tsx b/src/app/(site)/events/page.tsx new file mode 100644 index 0000000000..9fe37ec8a0 --- /dev/null +++ b/src/app/(site)/events/page.tsx @@ -0,0 +1,133 @@ +import Image from "next/image"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ContentCard, EmptyState } from "@/components/public/ui"; +import { excerpt, slugify } from "@/lib/format"; +import { formatDate } from "@/lib/format-date"; +import { prisma } from "@/lib/prisma"; + +export const revalidate = 60; + +export const metadata = { title: "Events" }; + +export default async function EventsPage() { + const t = await getTranslations("pages.events"); + + const events = await prisma.websiteEvent + .findMany({ + where: { status: { in: ["published", "completed"] } }, + orderBy: { startsAt: "desc" }, + take: 50, + include: { + type: { select: { name: true, color: true, slug: true } }, + _count: { select: { registrations: true } }, + }, + }) + .catch(() => []); + + return ( +
+ + + + {events.length === 0 ? ( + {t("empty")} + ) : ( +
+ {events.map((event) => { + const href = `/events/${slugify(event.title)}-${event.id}`; + const spots = + event.maxPlayers != null + ? t("spots", { + count: event._count.registrations, + max: event.maxPlayers, + }) + : t("registered", { count: event._count.registrations }); + + return ( + + {event.image ? ( +
+ +
+ ) : ( +
+ )} +
+

+ {event.type.name} + {event.status === "completed" + ? ` · ${t("statusCompleted")}` + : ""} +

+

+ {event.title} +

+

+ {formatDate(event.startsAt, "datetime", "")} +

+

+ {excerpt(event.description, 120)} +

+

+ {spots} +

+
+ + ); + })} +
+ )} + +
+ ); +} diff --git a/src/app/(site)/forgot/page.tsx b/src/app/(site)/forgot/page.tsx index eaba02246f..289fea1b7c 100644 --- a/src/app/(site)/forgot/page.tsx +++ b/src/app/(site)/forgot/page.tsx @@ -1,15 +1,20 @@ +import { headers } from "next/headers"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { requestReset } from "@/actions/password-reset"; +import { CaptchaWidget } from "@/components/auth/captcha-widget"; import { ContentCard } from "@/components/public/ui"; +import { captchaConfig } from "@/lib/services/captcha"; export default async function ForgotPage({ searchParams, }: { - searchParams: Promise<{ sent?: string }>; + searchParams: Promise<{ sent?: string; error?: string }>; }) { const t = await getTranslations("pages.forgot"); - const { sent } = await searchParams; + const { sent, error } = await searchParams; + const cfg = await captchaConfig(); + const nonce = (await headers()).get("x-nonce") ?? undefined; return (
@@ -30,6 +35,25 @@ export default async function ForgotPage({ autoComplete="email" required /> + + {error === "captcha" ? ( +

+ {t("errorCaptcha")} +

+ ) : null} diff --git a/src/app/(site)/friends/loading.tsx b/src/app/(site)/friends/loading.tsx new file mode 100644 index 0000000000..66c1d10c8a --- /dev/null +++ b/src/app/(site)/friends/loading.tsx @@ -0,0 +1,15 @@ +export default function FriendsLoading() { + return ( +
+
+
+ ); +} diff --git a/src/app/(site)/friends/page.tsx b/src/app/(site)/friends/page.tsx index f1388039fe..d1463b1462 100644 --- a/src/app/(site)/friends/page.tsx +++ b/src/app/(site)/friends/page.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; +import { removeFriendship } from "@/actions/messenger"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { auth } from "@/lib/auth"; import { avatarImageUrl } from "@/lib/format"; @@ -11,16 +13,38 @@ export const dynamic = "force-dynamic"; export const metadata = { title: "Friends" }; -export default async function FriendsPage() { +type SearchParams = Promise<{ removed?: string; error?: string }>; + +function feedbackStyle(tone: "success" | "error"): CSSProperties { + const accent = + tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; + return { + margin: 0, + padding: "0.85rem 1rem", + borderRadius: "var(--radius-md)", + border: `1px solid ${accent}`, + color: "var(--color-text-readable, var(--color-text))", + fontSize: "0.9rem", + fontWeight: 600, + background: "var(--color-surface)", + borderLeft: `4px solid ${accent}`, + }; +} + +export default async function FriendsPage({ + searchParams, +}: { + searchParams: SearchParams; +}) { const t = await getTranslations("pages.friends"); const session = await auth(); if (!session?.user?.id) redirect("/login"); const userId = Number(session.user.id); + const { removed, error } = await searchParams; - // Friendships are bidirectional and stored once: the session user can appear - // as either user_one_id or user_two_id, with the *other* column being the - // friend. Read both directions, then dedupe the friend ids. + // Friendships are stored as TWO directional rows (user_one_id→user_two_id + // and the reverse). Read both directions, then dedupe the friend ids. const friendships = await prisma.messengerFriendships .findMany({ where: { @@ -60,8 +84,28 @@ export default async function FriendsPage() { ), ]); + const errorMessage = + error === "not_found" + ? t("errors.notFound") + : error === "invalid" + ? t("errors.invalid") + : error + ? t("errors.error") + : null; + return (
+ {removed === "1" ? ( +
+ {t("success.removed")} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ + +
); })} diff --git a/src/app/(site)/guilds/[id]/forum/new/page.tsx b/src/app/(site)/guilds/[id]/forum/new/page.tsx index 1db6e31adb..a25f19486f 100644 --- a/src/app/(site)/guilds/[id]/forum/new/page.tsx +++ b/src/app/(site)/guilds/[id]/forum/new/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; import { postThread } from "@/actions/social"; import { ContentCard } from "@/components/public/ui"; import { auth } from "@/lib/auth"; @@ -8,13 +9,34 @@ import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; +type SearchParams = Promise<{ error?: string }>; + +function feedbackStyle(tone: "success" | "error"): CSSProperties { + const accent = + tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; + return { + margin: 0, + padding: "0.85rem 1rem", + borderRadius: "var(--radius-md)", + border: `1px solid ${accent}`, + color: "var(--color-text-readable, var(--color-text))", + fontSize: "0.9rem", + fontWeight: 600, + background: "var(--color-surface)", + borderLeft: `4px solid ${accent}`, + }; +} + export default async function NewThreadPage({ params, + searchParams, }: { params: Promise<{ id: string }>; + searchParams: SearchParams; }) { const t = await getTranslations("pages.guildForumNew"); const { id } = await params; + const { error } = await searchParams; const guildId = Number(id); if (!Number.isInteger(guildId) || guildId <= 0) notFound(); @@ -35,8 +57,25 @@ export default async function NewThreadPage({ if (!guild) notFound(); + const errorMessage = + error === "invalid" + ? t("errors.invalid") + : error === "not_found" + ? t("errors.notFound") + : error === "ratelimit" + ? t("errors.ratelimit") + : error + ? t("errors.error") + : null; + return (
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +

{t("backToForum")}

diff --git a/src/app/(site)/guilds/[id]/forum/page.tsx b/src/app/(site)/guilds/[id]/forum/page.tsx index 9fd3137806..aaf0c55b3a 100644 --- a/src/app/(site)/guilds/[id]/forum/page.tsx +++ b/src/app/(site)/guilds/[id]/forum/page.tsx @@ -1,11 +1,30 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; export const revalidate = 300; +type SearchParams = Promise<{ posted?: string }>; + +function feedbackStyle(tone: "success" | "error"): CSSProperties { + const accent = + tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; + return { + margin: 0, + padding: "0.85rem 1rem", + borderRadius: "var(--radius-md)", + border: `1px solid ${accent}`, + color: "var(--color-text-readable, var(--color-text))", + fontSize: "0.9rem", + fontWeight: 600, + background: "var(--color-surface)", + borderLeft: `4px solid ${accent}`, + }; +} + type ThreadRow = { id: number; openerId: number | null; @@ -24,11 +43,14 @@ function formatTimestamp(ts: number | null | undefined): string { export default async function GuildForumPage({ params, + searchParams, }: { params: Promise<{ id: string }>; + searchParams: SearchParams; }) { const t = await getTranslations("pages.guildForum"); const { id } = await params; + const { posted } = await searchParams; const guildId = Number(id); if (!Number.isInteger(guildId) || guildId <= 0) notFound(); @@ -89,6 +111,12 @@ export default async function GuildForumPage({ return (
+ {posted === "1" ? ( +
+ {t("success.posted")} +
+ ) : null} +

{t("backToGuild")}

diff --git a/src/app/(site)/help/tickets/page.tsx b/src/app/(site)/help/tickets/page.tsx index b80863411e..2c2ddb28ee 100644 --- a/src/app/(site)/help/tickets/page.tsx +++ b/src/app/(site)/help/tickets/page.tsx @@ -1,19 +1,42 @@ import { redirect } from "next/navigation"; -import { formatDate } from "@/lib/format-date"; import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; import { createTicket } from "@/actions/help-tickets"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { auth } from "@/lib/auth"; +import { formatDate } from "@/lib/format-date"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; +type SearchParams = Promise<{ created?: string; error?: string }>; -export default async function HelpTicketsPage() { +function feedbackStyle(tone: "success" | "error"): CSSProperties { + const accent = + tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; + return { + margin: 0, + padding: "0.85rem 1rem", + borderRadius: "var(--radius-md)", + border: `1px solid ${accent}`, + color: "var(--color-text-readable, var(--color-text))", + fontSize: "0.9rem", + fontWeight: 600, + background: "var(--color-surface)", + borderLeft: `4px solid ${accent}`, + }; +} + +export default async function HelpTicketsPage({ + searchParams, +}: { + searchParams: SearchParams; +}) { const session = await auth(); if (!session?.user?.id) redirect("/login"); const t = await getTranslations("pages.helpTickets"); + const { created, error } = await searchParams; const userId = Number(session.user.id); @@ -33,8 +56,30 @@ export default async function HelpTicketsPage() { const openCount = tickets.filter((t) => t.open).length; const closedCount = tickets.length - openCount; + const errorMessage = + error === "invalid" + ? t("errors.invalid") + : error === "moderated" + ? t("errors.moderated") + : error === "ratelimit" + ? t("errors.ratelimit") + : error + ? t("errors.error") + : null; + return (
+ {created === "1" ? ( +
+ {t("success.created")} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
@@ -99,7 +144,9 @@ export default async function HelpTicketsPage() { {t2.open ? t("statusOpen") : t("statusClosed")} - {formatDate(t2.createdAt, "datetime", "")} + + {formatDate(t2.createdAt, "datetime", "")} + ))} diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index a5960f8d79..2447e2d704 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -1,158 +1,19 @@ -"use client"; +import { headers } from "next/headers"; +import { LoginForm } from "@/components/auth/login-form"; +import { captchaConfig } from "@/lib/services/captcha"; -import Link from "next/link"; -import { signIn } from "next-auth/react"; -import { useTranslations } from "next-intl"; -import { type FormEvent, useState } from "react"; -import { precheckLogin } from "@/actions/auth-precheck"; -import { ContentCard } from "@/components/public/ui"; - -export default function LoginPage() { - const t = useTranslations("pages.login"); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [code, setCode] = useState(""); - const [needs2fa, setNeeds2fa] = useState(false); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - - async function onSubmit(e: FormEvent) { - e.preventDefault(); - setError(null); - setPending(true); - try { - if (!needs2fa) { - const pre = await precheckLogin(username, password); - if (pre === "invalid") { - setError(t("errorInvalidCredentials")); - return; - } - if (pre === "twofactor") { - setNeeds2fa(true); - return; - } - } - const res = await signIn("credentials", { - username, - password, - code, - redirect: false, - }); - if (!res || res.error) { - setError( - needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"), - ); - return; - } - window.location.href = "/"; - } finally { - setPending(false); - } - } +export default async function LoginPage() { + const cfg = await captchaConfig(); + const nonce = (await headers()).get("x-nonce") ?? undefined; return ( -
- -
- setUsername(e.target.value)} - placeholder={t("usernamePlaceholder")} - autoComplete="username" - disabled={needs2fa} - /> - setPassword(e.target.value)} - placeholder={t("passwordPlaceholder")} - autoComplete="current-password" - disabled={needs2fa} - /> - {needs2fa ? ( - setCode(e.target.value)} - placeholder={t("codePlaceholder")} - inputMode="numeric" - autoComplete="one-time-code" - // biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step - autoFocus - /> - ) : null} - -
- - {!needs2fa ? ( - <> -
- - {t("or")} - -
-
- - -
- - ) : null} - - {error ? ( -

- {error} -

- ) : null} -

- {t("noAccount")} {t("createOne")} ·{" "} - {t("forgotPassword")} -

-
-
+ ); } diff --git a/src/app/(site)/messages/loading.tsx b/src/app/(site)/messages/loading.tsx new file mode 100644 index 0000000000..059885f9b9 --- /dev/null +++ b/src/app/(site)/messages/loading.tsx @@ -0,0 +1,15 @@ +export default function MessagesLoading() { + return ( +
+
+
+ ); +} diff --git a/src/app/(site)/messages/page.tsx b/src/app/(site)/messages/page.tsx index dab734ce8c..ace0bccb58 100644 --- a/src/app/(site)/messages/page.tsx +++ b/src/app/(site)/messages/page.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { acceptFriend } from "@/actions/messenger"; +import type { CSSProperties } from "react"; +import { acceptFriend, declineFriendRequest } from "@/actions/messenger"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; import { avatarImageUrl } from "@/lib/format"; @@ -10,18 +11,45 @@ import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; +type SearchParams = Promise<{ + accepted?: string; + declined?: string; + error?: string; +}>; + +function feedbackStyle(tone: "success" | "error"): CSSProperties { + const accent = + tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; + return { + margin: 0, + padding: "0.85rem 1rem", + borderRadius: "var(--radius-md)", + border: `1px solid ${accent}`, + color: "var(--color-text-readable, var(--color-text))", + fontSize: "0.9rem", + fontWeight: 600, + background: "var(--color-surface)", + borderLeft: `4px solid ${accent}`, + }; +} + function formatTimestamp(seconds: number): string { if (!seconds) return ""; // sended_on is a unix timestamp in seconds (emulator convention). return new Date(seconds * 1000).toISOString().slice(0, 16).replace("T", " "); } -export default async function MessagesPage() { +export default async function MessagesPage({ + searchParams, +}: { + searchParams: SearchParams; +}) { const t = await getTranslations("pages.messages"); const session = await auth(); if (!session?.user?.id) redirect("/login"); const userId = Number(session.user.id); + const { accepted, declined, error } = await searchParams; // Pending friend requests addressed to the session user, the most recent // offline messages they've received, and the avatar-imager base URL. @@ -85,8 +113,35 @@ export default async function MessagesPage() { } } + const errorMessage = + error === "not_found" + ? t("errors.notFound") + : error === "unauthorized" + ? t("errors.unauthorized") + : error === "invalid" + ? t("errors.invalid") + : error + ? t("errors.error") + : null; + return (
+ {accepted === "1" ? ( +
+ {t("success.accepted")} +
+ ) : null} + {declined === "1" ? ( +
+ {t("success.declined")} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {/* ── Pending friend requests ───────────────────────────── */} @@ -144,16 +199,34 @@ export default async function MessagesPage() { {t("wantsToBeFriend")}

-
- - -
+
+
+ + +
+
+ + +
+
); })} diff --git a/src/app/(site)/news/[slug]/page.tsx b/src/app/(site)/news/[slug]/page.tsx index a007335801..1ff4f4c774 100644 --- a/src/app/(site)/news/[slug]/page.tsx +++ b/src/app/(site)/news/[slug]/page.tsx @@ -1,20 +1,39 @@ import type { Metadata } from "next"; -import { formatDate } from "@/lib/format-date"; import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; import { postComment } from "@/actions/article-comments"; import { toggleReaction } from "@/actions/article-reactions"; import { ContentCard, EmptyState } from "@/components/public/ui"; +import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { auth } from "@/lib/auth"; import { excerpt } from "@/lib/format"; +import { formatDate } from "@/lib/format-date"; import { prisma } from "@/lib/prisma"; import { cacheQuery } from "@/lib/prisma-cache"; import { sanitize } from "@/lib/sanitize"; -import { SanitizedHtml } from "@/components/shared/sanitized-html"; export const revalidate = 60; +type SearchParams = Promise<{ comment?: string; error?: string }>; + +function feedbackStyle(tone: "success" | "error"): CSSProperties { + const accent = + tone === "error" ? "var(--color-danger)" : "var(--color-primary)"; + return { + margin: 0, + padding: "0.85rem 1rem", + borderRadius: "var(--radius-md)", + border: `1px solid ${accent}`, + color: "var(--color-text-readable, var(--color-text))", + fontSize: "0.9rem", + fontWeight: 600, + background: "var(--color-surface)", + borderLeft: `4px solid ${accent}`, + }; +} + export async function generateStaticParams() { const slugs = await prisma.websiteArticles .findMany({ @@ -62,10 +81,13 @@ const REACTIONS: { key: string; emoji: string }[] = [ export default async function ArticlePage({ params, + searchParams, }: { params: Promise<{ slug: string }>; + searchParams: SearchParams; }) { const { slug } = await params; + const { comment, error } = await searchParams; const t = await getTranslations("pages.article"); const article = await cacheQuery( @@ -145,8 +167,34 @@ export default async function ArticlePage({ } } + const errorMessage = + error === "empty" + ? t("errors.empty") + : error === "moderated" + ? t("errors.moderated") + : error === "ratelimit" + ? t("errors.ratelimit") + : error === "invalid" + ? t("errors.invalid") + : error === "not_found" + ? t("errors.notFound") + : error + ? t("errors.error") + : null; + return (
+ {comment === "posted" ? ( +
+ {t("success.posted")} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +