"use server"; 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 = | "accepted" | "declined" | "removed" | "not_found" | "unauthorized" | "invalid" | "ratelimit" | "error"; type SendOutcome = | "sent" | "not_friend" | "empty" | "invalid" | "rate_limited" | "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}`); } 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. * * The ACCEPTER is re-read from the session via auth() and is never trusted from * the submitted FormData. Only the request id comes from the form, and the * request is only honoured when its target (user_to_id) is the session user — * so a crafted form cannot accept a request addressed to someone else. * * Arcturus/AtomCMS stores friendships as TWO directional rows in * 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 { let outcome: FriendOutcome = "error"; try { const session = await auth(); const meId = Number(session?.user?.id); if (!Number.isInteger(meId) || meId <= 0) { 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"; } 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); 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"; } } } } } 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"); } 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"; } 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"); } 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"; } 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); } /** * 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); }