diff --git a/prisma/migrations/0017_website_jwt_version.sql b/prisma/migrations/0017_website_jwt_version.sql new file mode 100644 index 00000000..6bcc46e7 --- /dev/null +++ b/prisma/migrations/0017_website_jwt_version.sql @@ -0,0 +1,4 @@ +-- CMS JWT invalidation counter on users (stateless NextAuth sessions). +-- Idempotent (MariaDB). +ALTER TABLE users + ADD COLUMN IF NOT EXISTS website_jwt_version INT NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b63ab29..a578a645 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,8 @@ model User { twoFactorSecret String? @map("two_factor_secret") @db.Text twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0) + // Bumped to invalidate all CMS JWT sessions ("sign out everywhere"). + websiteJwtVersion Int @default(0) @map("website_jwt_version") createdTickets WebsiteTicket[] @relation("ticketCreator") assignedTickets WebsiteTicket[] @relation("ticketAssignee") diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index b369e93b..4531359b 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -2,10 +2,13 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { z } from "zod"; import { requirePermission } from "@/lib/admin/guard"; -import { PERMS } from "@/lib/permissions"; import { formPositiveBigInt } from "@/lib/form-data"; +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 { logStaffActivity } from "@/lib/services/staff-activity"; // CRUD for website advertisements (website_ads). Emulator does not own this @@ -70,7 +73,35 @@ export async function updateAd(formData: FormData): Promise { redirect("/admin/ads"); } -export async function deleteAd(formData: FormData): Promise { +const deleteAdInput = z.object({ + id: z + .union([z.string(), z.number(), z.bigint()]) + .transform((v) => BigInt(String(v))), +}); + +export const deleteAd = adminAction( + { permission: PERMS.PAGES_EDIT, schema: deleteAdInput }, + async (ctx) => { + const id = ctx.data.id; + try { + await prisma.websiteAds.delete({ where: { id } }); + } catch { + throw new ActionError("Advertisement not found"); + } + await logStaffActivity({ + staffId: Number(ctx.session.user.id), + action: "ad_delete", + description: `Deleted advertisement #${id}`, + targetType: "website_ad", + targetId: Number(id), + }); + revalidatePath("/admin/ads"); + return actionOk(); + }, +); + +/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */ +export async function deleteAdForm(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) return; diff --git a/src/actions/applications.ts b/src/actions/applications.ts index 63cd361a..ee0a1466 100644 --- a/src/actions/applications.ts +++ b/src/actions/applications.ts @@ -1,98 +1,146 @@ "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"; // AtomCMS validates the application body with `min:10`. Mirror that floor and // cap the write defensively (column is TEXT, but we keep applications sane). const CONTENT_MIN = 10; const CONTENT_MAX = 5000; +type ApplyOutcome = + | "submitted" + | "empty" + | "invalid" + | "duplicate" + | "ratelimit" + | "error"; + +function staffRedirect(outcome: ApplyOutcome): never { + if (outcome === "submitted") redirect("/apply/staff?submitted=1"); + redirect(`/apply/staff?error=${outcome}`); +} + +function teamRedirect(outcome: ApplyOutcome): never { + if (outcome === "submitted") redirect("/apply/team?submitted=1"); + redirect(`/apply/team?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") + ); +} + /** * Submit a STAFF application for an open position. - * - * Faithful to AtomCMS's StaffApplicationsController@store: - * - the applicant (user_id) is re-read from the session via auth() and is - * NEVER trusted from the submitted FormData; - * - rank_id is the open position's permission id (the rank being applied for); - * - a user may only apply once per rank (idempotency guard); - * - content must be at least 10 characters. */ export async function applyStaff(formData: FormData): Promise { - const session = await auth(); - const userId = Number(session?.user?.id); - if (!Number.isInteger(userId) || userId <= 0) return; - - // rank_id comes from the open position's permission_id (an Int in the schema). - const rankId = Number(formData.get("rankId")); - if (!Number.isInteger(rankId) || rankId <= 0) return; - - const content = String(formData.get("content") ?? "") - .normalize("NFC") - .trim() - .slice(0, CONTENT_MAX); - if (content.length < CONTENT_MIN) return; + let outcome: ApplyOutcome = "error"; try { - // Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition). - const existing = await prisma.websiteStaffApplications.findFirst({ - where: { userId, rankId }, - select: { id: true }, - }); - if (existing) return; + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + redirect("/login"); + } - const now = new Date(); - await prisma.websiteStaffApplications.create({ - data: { userId, rankId, content, createdAt: now, updatedAt: now }, - }); - } catch { - // DB unavailable — fail soft; nothing to persist. - return; + await clientIp(); + if (!(await rateLimit(`apply-staff:${userId}`, 3, 60_000)).ok) { + outcome = "ratelimit"; + } else { + const rankId = Number(formData.get("rankId")); + if (!Number.isInteger(rankId) || rankId <= 0) { + outcome = "invalid"; + } else { + const content = String(formData.get("content") ?? "") + .normalize("NFC") + .trim() + .slice(0, CONTENT_MAX); + if (content.length < CONTENT_MIN) { + outcome = "empty"; + } else { + const existing = await prisma.websiteStaffApplications.findFirst({ + where: { userId, rankId }, + select: { id: true }, + }); + if (existing) { + outcome = "duplicate"; + } else { + const now = new Date(); + await prisma.websiteStaffApplications.create({ + data: { userId, rankId, content, createdAt: now, updatedAt: now }, + }); + outcome = "submitted"; + } + } + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; } revalidatePath("/apply/staff"); + staffRedirect(outcome); } /** * Submit a TEAM application. - * - * The Prisma `website_staff_applications` slice has no dedicated team column, so - * (per the conversion brief) team applications REUSE the staff-applications - * table with the team acting as the rank: rank_id carries the team id. The - * applicant is re-read from the session, never trusted from the form, and a user - * may only apply once per team. */ export async function applyTeam(formData: FormData): Promise { - const session = await auth(); - const userId = Number(session?.user?.id); - if (!Number.isInteger(userId) || userId <= 0) return; - - // website_teams.id is a BigInt; rank_id on the application is an Int. The team - // id is the application's rank flag. - const rankId = Number(formData.get("teamId")); - if (!Number.isInteger(rankId) || rankId <= 0) return; - - const content = String(formData.get("content") ?? "") - .normalize("NFC") - .trim() - .slice(0, CONTENT_MAX); - if (content.length < CONTENT_MIN) return; + let outcome: ApplyOutcome = "error"; try { - const existing = await prisma.websiteStaffApplications.findFirst({ - where: { userId, rankId }, - select: { id: true }, - }); - if (existing) return; + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + redirect("/login"); + } - const now = new Date(); - await prisma.websiteStaffApplications.create({ - data: { userId, rankId, content, createdAt: now, updatedAt: now }, - }); - } catch { - return; + await clientIp(); + if (!(await rateLimit(`apply-team:${userId}`, 3, 60_000)).ok) { + outcome = "ratelimit"; + } else { + const rankId = Number(formData.get("teamId")); + if (!Number.isInteger(rankId) || rankId <= 0) { + outcome = "invalid"; + } else { + const content = String(formData.get("content") ?? "") + .normalize("NFC") + .trim() + .slice(0, CONTENT_MAX); + if (content.length < CONTENT_MIN) { + outcome = "empty"; + } else { + const existing = await prisma.websiteStaffApplications.findFirst({ + where: { userId, rankId }, + select: { id: true }, + }); + if (existing) { + outcome = "duplicate"; + } else { + const now = new Date(); + await prisma.websiteStaffApplications.create({ + data: { userId, rankId, content, createdAt: now, updatedAt: now }, + }); + outcome = "submitted"; + } + } + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; } revalidatePath("/apply/team"); + teamRedirect(outcome); } diff --git a/src/actions/article-reactions.ts b/src/actions/article-reactions.ts index 9c0e7799..e1374035 100644 --- a/src/actions/article-reactions.ts +++ b/src/actions/article-reactions.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; @@ -9,91 +10,106 @@ import { prisma } from "@/lib/prisma"; // values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx. const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]); +type ReactionOutcome = "updated" | "invalid" | "not_found" | "error"; + +function reactionRedirect(slug: string, outcome: ReactionOutcome): never { + const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news"; + if (outcome === "updated") redirect(`${path}?reaction=1`); + 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") + ); +} + /** * Toggle the SIGNED-IN user's reaction on a news article. - * - * The voter id is read from the session (re-fetched via auth()), never from the - * submitted FormData, so a crafted form cannot vote as another account. A user - * has at most one ACTIVE reaction per article: - * - clicking the reaction they already have active -> deactivates it (un-vote) - * - clicking a different reaction -> that reaction becomes active and any other - * reaction rows for this user/article are deactivated - * - first-ever reaction of a type -> a new active row is created - * - * Rows are toggled (active flag) rather than deleted so a user's history of - * reaction types is preserved. website_article_reactions has no composite - * unique key, so we resolve the existing row with findFirst rather than upsert. */ export async function toggleReaction(formData: FormData): Promise { - const session = await auth(); - if (!session?.user?.id) return; - - const userId = Number(session.user.id); - if (!Number.isFinite(userId)) return; - - const reaction = String(formData.get("reaction") ?? "") - .normalize("NFC") - .trim() - .toLowerCase(); - if (!ALLOWED_REACTIONS.has(reaction)) 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: ReactionOutcome = "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)) { + redirect("/login"); + } - // The user's current row for THIS reaction on THIS article, if any. - const existing = await prisma.websiteArticleReactions.findFirst({ - where: { userId, articleId, reaction }, - select: { id: true, active: true }, - }); - - if (existing?.active) { - // Already reacting with this exact reaction -> un-vote (deactivate it). - await prisma.websiteArticleReactions.update({ - where: { id: existing.id }, - data: { active: false }, - }); + const reaction = String(formData.get("reaction") ?? "") + .normalize("NFC") + .trim() + .toLowerCase(); + if (!ALLOWED_REACTIONS.has(reaction)) { + outcome = "invalid"; } else { - // Switching to (or first-time picking) this reaction: clear any other - // active reaction by this user on this article, then activate this one. - await prisma.websiteArticleReactions.updateMany({ - where: { userId, articleId, active: true }, - data: { active: false }, - }); - - if (existing) { - await prisma.websiteArticleReactions.update({ - where: { id: existing.id }, - data: { active: true }, - }); + const articleIdRaw = String(formData.get("articleId") ?? "") + .normalize("NFC") + .trim(); + if (!/^\d+$/.test(articleIdRaw)) { + outcome = "invalid"; } else { - await prisma.websiteArticleReactions.create({ - data: { userId, articleId, reaction, active: true }, + 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 existing = await prisma.websiteArticleReactions.findFirst({ + where: { userId, articleId, reaction }, + select: { id: true, active: true }, + }); + + if (existing?.active) { + await prisma.websiteArticleReactions.update({ + where: { id: existing.id }, + data: { active: false }, + }); + } else { + await prisma.websiteArticleReactions.updateMany({ + where: { userId, articleId, active: true }, + data: { active: false }, + }); + + if (existing) { + await prisma.websiteArticleReactions.update({ + where: { id: existing.id }, + data: { active: true }, + }); + } else { + await prisma.websiteArticleReactions.create({ + data: { userId, articleId, reaction, active: true }, + }); + } + } + outcome = "updated"; + } } } - } catch { - // DB unavailable — fail soft; nothing to persist. - return; + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; } - if (slug) revalidatePath(`/news/${slug}`); + if (slug && outcome !== "error") revalidatePath(`/news/${slug}`); + reactionRedirect(slug, outcome); } diff --git a/src/actions/draw-badge.ts b/src/actions/draw-badge.ts index d48c13c5..d8e93073 100644 --- a/src/actions/draw-badge.ts +++ b/src/actions/draw-badge.ts @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import type { Prisma } from "@/generated/prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { rcon } from "@/lib/services/rcon"; import { siteSettings } from "@/lib/services/site-settings"; @@ -55,10 +56,14 @@ export async function buyBadge(formData: FormData): Promise { .trim(); if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid"); - let outcome: "bought" | "invalid" | "credits" | "fail"; + let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail"; let boughtCode = ""; try { + await clientIp(); + if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) { + outcome = "ratelimit"; + } else { const badge = await prisma.websiteDrawbadges.findUnique({ where: { id: BigInt(rawId) }, select: { id: true, badgePath: true, published: true }, @@ -115,6 +120,7 @@ export async function buyBadge(formData: FormData): Promise { } } } + } } catch { outcome = "fail"; } diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index fc6a9ab1..a30b5729 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -6,7 +6,9 @@ import { z } from "zod"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { positiveBigInt } from "@/lib/api"; import { moderateOrThrow } from "@/lib/services/moderation"; +import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; const ticketSchema = z.object({ title: z.string().min(1, "Title is required").max(255), @@ -20,11 +22,32 @@ type TicketOutcome = | "ratelimit" | "error"; +type TicketDetailOutcome = + | "replied" + | "closed" + | "invalid" + | "not_found" + | "closed_ticket" + | "moderated" + | "ratelimit" + | "error"; + function ticketsRedirect(outcome: TicketOutcome): never { if (outcome === "created") redirect("/help/tickets?created=1"); redirect(`/help/tickets?error=${outcome}`); } +function ticketDetailRedirect( + ticketId: bigint | null, + outcome: TicketDetailOutcome, +): never { + if (!ticketId) redirect("/help/tickets?error=invalid"); + const base = `/help/tickets/${ticketId}`; + if (outcome === "replied") redirect(`${base}?replied=1`); + if (outcome === "closed") redirect(`${base}?closed=1`); + redirect(`${base}?error=${outcome}`); +} + function isNextRedirect(e: unknown): boolean { return ( !!e && @@ -99,3 +122,149 @@ export async function createTicket(formData: FormData): Promise { revalidatePath("/help/tickets"); ticketsRedirect(outcome); } + +const replyContentSchema = z.object({ + content: z.string().min(1).max(5000), +}); + +export async function replyHelpTicket(formData: FormData): Promise { + const ticketId = positiveBigInt(String(formData.get("ticketId") ?? "")); + let outcome: TicketDetailOutcome = "error"; + + try { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + redirect("/login"); + } + + if (!ticketId) { + outcome = "invalid"; + } else { + await clientIp(); + if (!(await rateLimit(`ticket-reply:${userId}`, 5, 60_000)).ok) { + outcome = "ratelimit"; + } else { + const raw = { + content: String(formData.get("content") ?? "") + .normalize("NFC") + .trim() + .slice(0, 5000), + }; + const parsed = replyContentSchema.safeParse(raw); + if (!parsed.success) { + outcome = "invalid"; + } else { + const ticket = await prisma.websiteHelpCenterTickets.findUnique({ + where: { id: ticketId }, + select: { id: true, userId: true, open: true }, + }); + + if (!ticket || ticket.userId !== userId) { + outcome = "not_found"; + } else if (!ticket.open) { + outcome = "closed_ticket"; + } else { + let moderated = false; + try { + await moderateOrThrow(parsed.data.content); + } catch { + moderated = true; + outcome = "moderated"; + } + + if (!moderated) { + const created = await prisma.$transaction((tx) => + createOwnedTicketReply( + { + findTicket: (id) => + tx.websiteHelpCenterTickets.findUnique({ + where: { id }, + select: { id: true, userId: true, open: true }, + }), + createReply: (data) => + tx.websiteHelpCenterTicketReplies.create({ + data, + select: { + id: true, + userId: true, + content: true, + createdAt: true, + }, + }), + touchTicket: (id, updatedAt) => + tx.websiteHelpCenterTickets.update({ + where: { id }, + data: { updatedAt }, + select: { id: true }, + }), + }, + { + ticketId, + userId, + content: parsed.data.content, + }, + ), + ); + outcome = created ? "replied" : "not_found"; + } + } + } + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; + } + + if (ticketId) revalidatePath(`/help/tickets/${ticketId}`); + revalidatePath("/help/tickets"); + ticketDetailRedirect(ticketId, outcome); +} + +export async function closeHelpTicket(formData: FormData): Promise { + const ticketId = positiveBigInt(String(formData.get("ticketId") ?? "")); + let outcome: TicketDetailOutcome = "error"; + + try { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + redirect("/login"); + } + + if (!ticketId) { + outcome = "invalid"; + } else { + await clientIp(); + if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) { + outcome = "ratelimit"; + } else { + const ticket = await prisma.websiteHelpCenterTickets.findUnique({ + where: { id: ticketId }, + select: { id: true, userId: true, open: true }, + }); + + if (!ticket || ticket.userId !== userId) { + outcome = "not_found"; + } else if (!ticket.open) { + outcome = "closed_ticket"; + } else { + const now = new Date(); + await prisma.websiteHelpCenterTickets.update({ + where: { id: ticketId }, + data: { open: false, updatedAt: now }, + }); + outcome = "closed"; + } + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; + } + + if (ticketId) revalidatePath(`/help/tickets/${ticketId}`); + revalidatePath("/help/tickets"); + ticketDetailRedirect(ticketId, outcome); +} diff --git a/src/actions/messenger.ts b/src/actions/messenger.ts index 8070c798..ef0ab840 100644 --- a/src/actions/messenger.ts +++ b/src/actions/messenger.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; type FriendOutcome = @@ -12,6 +13,15 @@ type FriendOutcome = | "not_found" | "unauthorized" | "invalid" + | "ratelimit" + | "error"; + +type SendOutcome = + | "sent" + | "not_friend" + | "empty" + | "invalid" + | "rate_limited" | "error"; function messagesRedirect(outcome: FriendOutcome): never { @@ -25,6 +35,11 @@ function friendsRedirect(outcome: FriendOutcome): never { redirect(`/friends?error=${outcome}`); } +function sendRedirect(outcome: SendOutcome): never { + if (outcome === "sent") redirect("/messages?sent=1"); + redirect(`/messages?send_error=${outcome}`); +} + /** * Accept a pending friend request as the SIGNED-IN user. * @@ -52,6 +67,10 @@ export async function acceptFriend(formData: FormData): Promise { redirect("/login"); } + await clientIp(); + if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) { + outcome = "ratelimit"; + } else { const requestId = Number(formData.get("requestId")); if (!Number.isInteger(requestId) || requestId <= 0) { outcome = "invalid"; @@ -113,6 +132,7 @@ export async function acceptFriend(formData: FormData): Promise { } } } + } } catch (e) { // redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it. if ( @@ -148,6 +168,10 @@ export async function declineFriendRequest(formData: FormData): Promise { redirect("/login"); } + await clientIp(); + if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) { + outcome = "ratelimit"; + } else { const requestId = Number(formData.get("requestId")); if (!Number.isInteger(requestId) || requestId <= 0) { outcome = "invalid"; @@ -167,6 +191,7 @@ export async function declineFriendRequest(formData: FormData): Promise { outcome = "declined"; } } + } } catch (e) { if ( e && @@ -202,6 +227,10 @@ export async function removeFriendship(formData: FormData): Promise { redirect("/login"); } + await clientIp(); + if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) { + outcome = "ratelimit"; + } else { const friendId = Number(formData.get("friendId")); if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { outcome = "invalid"; @@ -230,6 +259,7 @@ export async function removeFriendship(formData: FormData): Promise { outcome = deleted > 0 ? "removed" : "not_found"; } + } } catch (e) { if ( e && @@ -247,3 +277,81 @@ export async function removeFriendship(formData: FormData): Promise { revalidatePath("/messages"); friendsRedirect(outcome); } + +/** + * Send an offline messenger message to a friend (Arcturus messenger_offline row). + * Only confirmed friendships may receive messages; body is capped at 500 chars. + */ +export async function sendOfflineMessage(formData: FormData): Promise { + let outcome: SendOutcome = "error"; + + try { + const session = await auth(); + const meId = Number(session?.user?.id); + if (!Number.isInteger(meId) || meId <= 0) { + redirect("/login"); + } + + if (!(await rateLimit(`offline-msg:${meId}`, 10, 60_000)).ok) { + outcome = "rate_limited"; + } else { + const friendId = Number(formData.get("friendId")); + const rawMessage = String(formData.get("message") ?? "") + .normalize("NFC") + .trim(); + const message = rawMessage.slice(0, 500); + + if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { + outcome = "invalid"; + } else if (!message) { + outcome = "empty"; + } else { + const friendship = await prisma.messengerFriendships.findFirst({ + where: { + OR: [ + { userOneId: meId, userTwoId: friendId }, + { userOneId: friendId, userTwoId: meId }, + ], + }, + select: { id: true }, + }); + + if (!friendship) { + outcome = "not_friend"; + } else { + const recipient = await prisma.user.findUnique({ + where: { id: friendId }, + select: { id: true }, + }); + if (!recipient) { + outcome = "invalid"; + } else { + await prisma.messengerOffline.create({ + data: { + userId: friendId, + userFromId: meId, + message, + sendedOn: Math.floor(Date.now() / 1000), + }, + }); + outcome = "sent"; + } + } + } + } + } 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"); + sendRedirect(outcome); +} diff --git a/src/actions/radio-apply.ts b/src/actions/radio-apply.ts index 4836d97d..d132332f 100644 --- a/src/actions/radio-apply.ts +++ b/src/actions/radio-apply.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"; // Column bounds from prisma/schema.prisma (radio_applications): // real_name VARCHAR(255); the rest are TEXT. age is an INT. @@ -10,6 +12,27 @@ const NAME_MAX = 255; const TEXT_MAX = 5000; const STYLE_MAX = 5000; +type ApplyOutcome = + | "submitted" + | "invalid" + | "ratelimit" + | "error"; + +function applyRedirect(outcome: ApplyOutcome): never { + if (outcome === "submitted") redirect("/radio/apply?submitted=1"); + redirect(`/radio/apply?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") + ); +} + function str(form: FormData, key: string, max: number): string { return String(form.get(key) ?? "") .normalize("NFC") @@ -19,50 +42,56 @@ function str(form: FormData, key: string, max: number): string { /** * Submit a radio DJ application. - * - * The applicant (userId) is ALWAYS re-read from the session via auth() and is - * never taken from the submitted FormData, so a crafted form cannot file an - * application on behalf of another account. radio_applications.user_id is an - * UnsignedBigInt, hence the BigInt() coercion. */ export async function applyDj(formData: FormData): Promise { - const session = await auth(); - const userId = Number(session?.user?.id); - if (!Number.isInteger(userId) || userId <= 0) return; + let outcome: ApplyOutcome = "error"; - const realName = str(formData, "realName", NAME_MAX); - const availability = str(formData, "availability", TEXT_MAX); - const motivation = str(formData, "motivation", TEXT_MAX); - const experience = str(formData, "experience", TEXT_MAX); - const musicStyle = str(formData, "musicStyle", STYLE_MAX); - - const ageRaw = Number(formData.get("age")); - const age = Number.isInteger(ageRaw) ? ageRaw : 0; - - // Required fields per the schema (NOT NULL): real_name, age, availability, - // motivation. experience + music_style are nullable. - if (!realName || !availability || !motivation || age <= 0) return; - - const now = new Date(); try { - await prisma.radioApplications.create({ - data: { - userId: BigInt(userId), - realName, - age, - availability, - motivation, - experience: experience || null, - musicStyle: musicStyle || null, - status: "pending", - createdAt: now, - updatedAt: now, - }, - }); - } catch { - // DB unavailable or duplicate — 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(`radio-apply:${userId}`, 2, 300_000)).ok) { + outcome = "ratelimit"; + } else { + const realName = str(formData, "realName", NAME_MAX); + const availability = str(formData, "availability", TEXT_MAX); + const motivation = str(formData, "motivation", TEXT_MAX); + const experience = str(formData, "experience", TEXT_MAX); + const musicStyle = str(formData, "musicStyle", STYLE_MAX); + + const ageRaw = Number(formData.get("age")); + const age = Number.isInteger(ageRaw) ? ageRaw : 0; + + if (!realName || !availability || !motivation || age <= 0) { + outcome = "invalid"; + } else { + const now = new Date(); + await prisma.radioApplications.create({ + data: { + userId: BigInt(userId), + realName, + age, + availability, + motivation, + experience: experience || null, + musicStyle: musicStyle || null, + status: "pending", + createdAt: now, + updatedAt: now, + }, + }); + outcome = "submitted"; + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; } revalidatePath("/radio/apply"); + applyRedirect(outcome); } diff --git a/src/actions/radio-requests.ts b/src/actions/radio-requests.ts index 8a053615..5407c880 100644 --- a/src/actions/radio-requests.ts +++ b/src/actions/radio-requests.ts @@ -1,42 +1,80 @@ "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"; const SONG_MAX = 255; const ARTIST_MAX = 255; +type RequestOutcome = + | "posted" + | "empty" + | "invalid" + | "ratelimit" + | "error"; + +function requestsRedirect(outcome: RequestOutcome): never { + if (outcome === "posted") redirect("/radio/requests?posted=1"); + redirect(`/radio/requests?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 submitRequest(formData: FormData): Promise { - const session = await auth(); - const userId = Number(session?.user?.id); - if (!Number.isInteger(userId) || userId <= 0) return; + let outcome: RequestOutcome = "error"; - const songTitle = String(formData.get("songTitle") ?? "") - .normalize("NFC") - .trim() - .slice(0, SONG_MAX); - const artist = String(formData.get("artist") ?? "") - .normalize("NFC") - .trim() - .slice(0, ARTIST_MAX); - if (!songTitle && !artist) return; - - const now = new Date(); try { - await prisma.radioSongRequests.create({ - data: { - userId: BigInt(userId), - songTitle: songTitle || null, - artist: artist || null, - submittedAt: now, - createdAt: now, - updatedAt: now, - }, - }); - } catch { - return; + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + redirect("/login"); + } + + await clientIp(); + if (!(await rateLimit(`radio-req:${userId}`, 5, 30_000)).ok) { + outcome = "ratelimit"; + } else { + const songTitle = String(formData.get("songTitle") ?? "") + .normalize("NFC") + .trim() + .slice(0, SONG_MAX); + const artist = String(formData.get("artist") ?? "") + .normalize("NFC") + .trim() + .slice(0, ARTIST_MAX); + if (!songTitle && !artist) { + outcome = "empty"; + } else { + const now = new Date(); + await prisma.radioSongRequests.create({ + data: { + userId: BigInt(userId), + songTitle: songTitle || null, + artist: artist || null, + submittedAt: now, + createdAt: now, + updatedAt: now, + }, + }); + outcome = "posted"; + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; } revalidatePath("/radio/requests"); + requestsRedirect(outcome); } diff --git a/src/actions/sessions.ts b/src/actions/sessions.ts new file mode 100644 index 00000000..6b5366fb --- /dev/null +++ b/src/actions/sessions.ts @@ -0,0 +1,28 @@ +"use server"; + +import { auth, signOut } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +/** + * Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version, + * then end the current browser session too. + */ +export async function signOutEverywhere(): Promise { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + await signOut({ redirectTo: "/login" }); + return; + } + + try { + await prisma.user.update({ + where: { id: userId }, + data: { websiteJwtVersion: { increment: 1 } }, + }); + } catch { + /* still sign out locally */ + } + + await signOut({ redirectTo: "/login?signedOutAll=1" }); +} diff --git a/src/actions/shop.ts b/src/actions/shop.ts new file mode 100644 index 00000000..ec7eddb3 --- /dev/null +++ b/src/actions/shop.ts @@ -0,0 +1,196 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import type { Prisma } from "@/generated/prisma/client"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { creditsPerUnit } from "@/lib/services/paypal"; +import { rcon } from "@/lib/services/rcon"; +import { sendCurrency } from "@/lib/services/send-currency"; + +/** + * Credits charged for a package row. AtomCMS stores `costs` in cents (USD display); + * we mirror the top-up rate so $1.00 of list price costs creditsPerUnit() credits. + */ +function creditPriceFromCosts(costs: number): number { + const rate = creditsPerUnit(); + const dollars = costs < 100 ? 1 : costs / 100; + return Math.max(1, Math.floor(dollars * rate)); +} + +function parseBadgeCodes(raw: string | null | undefined): string[] { + if (!raw?.trim()) return []; + return raw + .split(/[,;]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0 && s.length <= 32); +} + +type BuyOutcome = + | "bought" + | "invalid" + | "credits" + | "ratelimit" + | "error"; + +function shopRedirect( + categoryId: string, + outcome: BuyOutcome, + articleName?: string, +): never { + const params = new URLSearchParams(); + if (categoryId) params.set("category", categoryId); + if (outcome === "bought") { + params.set("bought", "1"); + if (articleName) params.set("package", articleName); + } else { + params.set("error", outcome); + } + const qs = params.toString(); + redirect(qs ? `/shop?${qs}` : "/shop"); +} + +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") + ); +} + +/** + * Purchase a website shop package with in-game credits (top up via /shop/topup first). + * The buyer id is always taken from the session, never from FormData. + */ +export async function buyShopArticle(formData: FormData): Promise { + const categoryId = String(formData.get("categoryId") ?? "") + .normalize("NFC") + .trim(); + const safeCategory = /^\d+$/.test(categoryId) ? categoryId : ""; + + let outcome: BuyOutcome = "error"; + let packageName = ""; + + try { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + redirect("/login"); + } + + await clientIp(); + if (!(await rateLimit(`shop-buy:${userId}`, 5, 60_000)).ok) { + outcome = "ratelimit"; + } else { + const rawId = String(formData.get("articleId") ?? "") + .normalize("NFC") + .trim(); + if (!/^\d+$/.test(rawId)) { + outcome = "invalid"; + } else { + const article = await prisma.websiteShopArticles.findUnique({ + where: { id: BigInt(rawId) }, + select: { + id: true, + name: true, + costs: true, + credits: true, + duckets: true, + diamonds: true, + badges: true, + giveRank: true, + }, + }); + + if (!article) { + outcome = "invalid"; + } else { + packageName = article.name; + const price = creditPriceFromCosts(article.costs); + + const buyer = await prisma.user.findUnique({ + where: { id: userId }, + select: { credits: true, rank: true }, + }); + if (!buyer || buyer.credits < price) { + outcome = "credits"; + } else { + const badgeCodes = parseBadgeCodes(article.badges); + + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + if (price > 0) { + await tx.user.update({ + where: { id: userId }, + data: { credits: { decrement: price } }, + }); + } + + if ( + article.giveRank != null && + article.giveRank > 0 && + article.giveRank > buyer.rank + ) { + await tx.user.update({ + where: { id: userId }, + data: { rank: article.giveRank }, + }); + } + + for (const code of badgeCodes) { + const existing = await tx.usersBadges.findFirst({ + where: { userId, badgeCode: code }, + select: { id: true }, + }); + if (!existing) { + const max = await tx.usersBadges.aggregate({ + where: { userId }, + _max: { slotId: true }, + }); + const slotId = (max._max.slotId ?? 0) + 1; + await tx.usersBadges.create({ + data: { userId, slotId, badgeCode: code }, + }); + } + } + }); + + await sendCurrency( + { rcon, db: prisma }, + userId, + "credits", + article.credits, + ); + await sendCurrency( + { rcon, db: prisma }, + userId, + "duckets", + article.duckets, + ); + await sendCurrency( + { rcon, db: prisma }, + userId, + "diamonds", + article.diamonds, + ); + + for (const code of badgeCodes) { + await rcon.giveBadge(userId, code).catch(() => {}); + } + + outcome = "bought"; + } + } + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; + } + + revalidatePath("/shop"); + shopRedirect(safeCategory, outcome, packageName || undefined); +} diff --git a/src/actions/social.ts b/src/actions/social.ts index 4922e947..4e02f32f 100644 --- a/src/actions/social.ts +++ b/src/actions/social.ts @@ -29,6 +29,14 @@ type ThreadOutcome = | "ratelimit" | "error"; +type ReplyOutcome = + | "replied" + | "invalid" + | "not_found" + | "locked" + | "ratelimit" + | "error"; + function profileRedirect( username: string, outcome: FriendRequestOutcome, @@ -47,6 +55,24 @@ function threadRedirect(guildId: number, outcome: ThreadOutcome): never { redirect(`${base}/new?error=${outcome}`); } +function threadReplyRedirect( + guildId: number, + threadId: number, + outcome: ReplyOutcome, +): never { + if ( + !Number.isInteger(guildId) || + guildId <= 0 || + !Number.isInteger(threadId) || + threadId <= 0 + ) { + redirect("/guilds"); + } + const base = `/guilds/${guildId}/forum/${threadId}`; + if (outcome === "replied") redirect(`${base}?replied=1`); + redirect(`${base}?error=${outcome}`); +} + function isNextRedirect(e: unknown): boolean { return ( !!e && @@ -239,3 +265,102 @@ export async function postThread(formData: FormData): Promise { } threadRedirect(guildId, outcome); } + +/** + * Reply to an existing guild forum thread. + * + * The AUTHOR (user_id) is re-read from the session via auth() and is never + * trusted from the submitted FormData. guildId, threadId, and message come + * from the form. + * + * Appends a row to guilds_forums_comments and bumps the thread's posts_count + * and updated_at to match emulator bookkeeping. Locked threads reject replies. + */ +export async function replyToThread(formData: FormData): Promise { + const guildId = Number(formData.get("guildId")); + const threadId = Number(formData.get("threadId")); + let outcome: ReplyOutcome = "error"; + + try { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + redirect("/login"); + } + + if ( + !Number.isInteger(guildId) || + guildId <= 0 || + !Number.isInteger(threadId) || + threadId <= 0 + ) { + outcome = "invalid"; + } else { + await clientIp(); + if (!(await rateLimit(`forum-reply:${userId}`, 5, 60_000)).ok) { + outcome = "ratelimit"; + } else { + const message = String(formData.get("message") ?? "") + .normalize("NFC") + .trim() + .slice(0, MESSAGE_MAX); + if (!message) { + outcome = "invalid"; + } else { + const now = Math.floor(Date.now() / 1000); + + const thread = await prisma.guildsForumsThreads.findFirst({ + where: { id: threadId, guildId, state: 0 }, + select: { + id: true, + locked: true, + postsCount: true, + }, + }); + + if (!thread) { + outcome = "not_found"; + } else if (thread.locked) { + outcome = "locked"; + } else { + await prisma.$transaction(async (tx) => { + await tx.guildsForumsComments.create({ + data: { + threadId: thread.id, + userId, + message, + createdAt: now, + state: 0, + adminId: 0, + }, + }); + + await tx.guildsForumsThreads.update({ + where: { id: thread.id }, + data: { + postsCount: (thread.postsCount ?? 0) + 1, + updatedAt: now, + }, + }); + }); + outcome = "replied"; + } + } + } + } + } catch (e) { + if (isNextRedirect(e)) throw e; + outcome = "error"; + } + + if ( + Number.isInteger(guildId) && + guildId > 0 && + Number.isInteger(threadId) && + threadId > 0 + ) { + revalidatePath(`/guilds/${guildId}/forum`); + revalidatePath(`/guilds/${guildId}/forum/${threadId}`); + } + threadReplyRedirect(guildId, threadId, outcome); +} diff --git a/src/actions/tickets.ts b/src/actions/tickets.ts index d171e579..4d67e013 100644 --- a/src/actions/tickets.ts +++ b/src/actions/tickets.ts @@ -17,7 +17,12 @@ import { // ── User actions (authenticated, no admin perms needed) ────────────── export const createTicket = authAction( - { schema: createTicketSchema }, + { + schema: createTicketSchema, + rateLimitKey: "ticket-create", + rateLimitMax: 5, + rateLimitWindowMs: 60_000, + }, async (ctx) => { const ticket = await prisma.websiteTicket.create({ data: { @@ -49,7 +54,12 @@ export const createTicket = authAction( ); export const userReplyTicket = authAction( - { schema: replyTicketSchema }, + { + schema: replyTicketSchema, + rateLimitKey: "ticket-reply", + rateLimitMax: 10, + rateLimitWindowMs: 60_000, + }, async (ctx) => { const ticket = await prisma.websiteTicket.findUnique({ where: { id: ctx.data.ticketId }, @@ -82,7 +92,12 @@ export const userReplyTicket = authAction( ); export const closeTicketByUser = authAction( - { schema: replyTicketSchema.pick({ ticketId: true }) }, + { + schema: replyTicketSchema.pick({ ticketId: true }), + rateLimitKey: "ticket-close", + rateLimitMax: 10, + rateLimitWindowMs: 60_000, + }, async (ctx) => { const ticket = await prisma.websiteTicket.findUnique({ where: { id: ctx.data.ticketId }, diff --git a/src/actions/voucher.ts b/src/actions/voucher.ts index c2f1ee32..2a5309ca 100644 --- a/src/actions/voucher.ts +++ b/src/actions/voucher.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; import { rcon } from "@/lib/services/rcon"; import { sendCurrency } from "@/lib/services/send-currency"; @@ -40,6 +41,14 @@ export async function redeem( }; } + await clientIp(); + if (!(await rateLimit(`voucher-redeem:${userId}`, 5, 60_000)).ok) { + return { + ok: false, + message: "You're redeeming too fast. Please wait a moment and try again.", + }; + } + const code = String(formData.get("code") ?? "") .normalize("NFC") .trim(); diff --git a/src/app/(site)/apply/staff/page.tsx b/src/app/(site)/apply/staff/page.tsx index 9f2c44d3..bf16a818 100644 --- a/src/app/(site)/apply/staff/page.tsx +++ b/src/app/(site)/apply/staff/page.tsx @@ -1,5 +1,6 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; import { applyStaff } from "@/actions/applications"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; @@ -11,11 +12,45 @@ export const dynamic = "force-dynamic"; // Canonical Habbo badge image CDN (same base used by the profile page). const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; -export default async function ApplyStaffPage() { +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 ApplyStaffPage({ + searchParams, +}: { + searchParams: Promise<{ submitted?: string; error?: string }>; +}) { const session = await auth(); if (!session?.user?.id) redirect("/login"); const t = await getTranslations("pages.applyStaff"); + const { submitted, error } = await searchParams; + + const errorMessage = + error === "empty" + ? t("errors.empty") + : error === "duplicate" + ? t("errors.duplicate") + : error === "ratelimit" + ? t("errors.ratelimit") + : error === "invalid" + ? t("errors.invalid") + : error + ? t("errors.error") + : null; const userId = Number(session.user.id); const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; @@ -53,6 +88,17 @@ export default async function ApplyStaffPage() { return (
+ {submitted === "1" ? ( +
+ {t("success.submitted")} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + ; +}) { const session = await auth(); if (!session?.user?.id) redirect("/login"); const t = await getTranslations("pages.applyTeam"); + const { submitted, error } = await searchParams; + + const errorMessage = + error === "empty" + ? t("errors.empty") + : error === "duplicate" + ? t("errors.duplicate") + : error === "ratelimit" + ? t("errors.ratelimit") + : error === "invalid" + ? t("errors.invalid") + : error + ? t("errors.error") + : null; const userId = Number(session.user.id); const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; @@ -40,6 +75,17 @@ export default async function ApplyTeamPage() { return (
+ {submitted === "1" ? ( +
+ {t("success.submitted")} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + const ERROR_NOTE: Record = { invalid: "That badge is no longer available.", credits: "You don't have enough credits to buy this badge.", + ratelimit: "You're purchasing too fast. Please wait a moment and try again.", fail: "Something went wrong. Please try again.", }; function getErrorNote(error: string): string { if (error === "invalid") return ERROR_NOTE.invalid; if (error === "credits") return ERROR_NOTE.credits; + if (error === "ratelimit") return ERROR_NOTE.ratelimit; return ERROR_NOTE.fail; } diff --git a/src/app/(site)/events/loading.tsx b/src/app/(site)/events/loading.tsx new file mode 100644 index 00000000..06350688 --- /dev/null +++ b/src/app/(site)/events/loading.tsx @@ -0,0 +1 @@ +export { default } from "../friends/loading"; diff --git a/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx b/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx new file mode 100644 index 00000000..4c488d66 --- /dev/null +++ b/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx @@ -0,0 +1,275 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; +import { replyToThread } from "@/actions/social"; +import { ContentCard, EmptyState } from "@/components/public/ui"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export const revalidate = 60; + +type SearchParams = Promise<{ replied?: 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(ts: number | null | undefined): string { + if (!ts) return ""; + return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); +} + +export default async function GuildForumThreadPage({ + params, + searchParams, +}: { + params: Promise<{ id: string; threadId: string }>; + searchParams: SearchParams; +}) { + const t = await getTranslations("pages.guildForumThread"); + const { id, threadId: threadIdRaw } = await params; + const { replied, error } = await searchParams; + const guildId = Number(id); + const threadId = Number(threadIdRaw); + if (!Number.isInteger(guildId) || guildId <= 0) notFound(); + if (!Number.isInteger(threadId) || threadId <= 0) notFound(); + + const session = await auth(); + const viewerId = Number(session?.user?.id); + const isLoggedIn = Number.isInteger(viewerId) && viewerId > 0; + + let guild: { id: number; name: string } | null = null; + let thread: { + id: number; + subject: string | null; + locked: number | null; + pinned: number | null; + openerId: number | null; + } | null = null; + let posts: { + id: number; + userId: number; + message: string; + createdAt: number; + }[] = []; + + try { + [guild, thread] = await Promise.all([ + prisma.guilds.findUnique({ + where: { id: guildId }, + select: { id: true, name: true }, + }), + prisma.guildsForumsThreads.findFirst({ + where: { id: threadId, guildId, state: 0 }, + select: { + id: true, + subject: true, + locked: true, + pinned: true, + openerId: true, + }, + }), + ]); + } catch { + guild = null; + thread = null; + } + + if (!guild || !thread) notFound(); + + try { + posts = await prisma.guildsForumsComments.findMany({ + where: { threadId: thread.id, state: 0 }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + userId: true, + message: true, + createdAt: true, + }, + }); + } catch { + posts = []; + } + + const authorIds = Array.from( + new Set(posts.map((p) => p.userId).filter((v) => v > 0)), + ); + let users: { id: number; username: string }[] = []; + try { + users = authorIds.length + ? await prisma.user.findMany({ + where: { id: { in: authorIds } }, + select: { id: true, username: true }, + }) + : []; + } catch { + users = []; + } + const usernameById = new Map(users.map((u) => [u.id, u.username])); + + const isLocked = !!thread.locked; + const canReply = isLoggedIn && !isLocked; + + const errorMessage = + error === "invalid" + ? t("errors.invalid") + : error === "not_found" + ? t("errors.notFound") + : error === "locked" + ? t("errors.locked") + : error === "ratelimit" + ? t("errors.ratelimit") + : error + ? t("errors.error") + : null; + + return ( +
+ {replied === "1" ? ( +
+ {t("success.replied")} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +

+ {t("backToForum")} +

+ + +
+ {thread.pinned ? ( + 📌 {t("pinned")} + ) : null} + {isLocked ? ( + 🔒 {t("locked")} + ) : null} +
+
+ + + {posts.length === 0 ? ( + {t("postsEmpty")} + ) : ( +
    + {posts.map((post) => { + const username = usernameById.get(post.userId); + return ( +
  • +
    + + {username ? ( + {username} + ) : ( + + {t("userNumber", { id: post.userId })} + + )} + + + {formatTimestamp(post.createdAt)} + +
    +

    + {post.message} +

    +
  • + ); + })} +
+ )} +
+ + {canReply ? ( + +
+ + +