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

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

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

No files matched your search

+191 -94
View File
@@ -1,8 +1,10 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
// guilds_forums_comments.message which is TEXT. Keep the first post's message
@@ -10,6 +12,51 @@ import { prisma } from "@/lib/prisma";
const SUBJECT_MAX = 255;
const MESSAGE_MAX = 10000;
type FriendRequestOutcome =
| "sent"
| "self"
| "invalid"
| "already_friends"
| "already_pending"
| "incoming_pending"
| "ratelimit"
| "error";
type ThreadOutcome =
| "posted"
| "invalid"
| "not_found"
| "ratelimit"
| "error";
function profileRedirect(
username: string,
outcome: FriendRequestOutcome,
): never {
const path = username ? `/u/${encodeURIComponent(username)}` : "/";
if (outcome === "sent") redirect(`${path}?friend=sent`);
redirect(`${path}?error=${outcome}`);
}
function threadRedirect(guildId: number, outcome: ThreadOutcome): never {
const base =
Number.isInteger(guildId) && guildId > 0
? `/guilds/${guildId}/forum`
: "/guilds";
if (outcome === "posted") redirect(`${base}?posted=1`);
redirect(`${base}/new?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Send a friend request to another user.
*
@@ -19,52 +66,81 @@ const MESSAGE_MAX = 10000;
*
* Writes into messenger_friendrequests (userFromId = requester, userToId =
* target). The emulator surfaces the pending request in the in-game messenger.
*
* Errors redirect back to the profile with a machine-readable ?error= code;
* success redirects with ?friend=sent. redirect() is called OUTSIDE the
* try/catch so its control-flow throw is never swallowed.
*/
export async function sendFriendRequest(formData: FormData): Promise<void> {
const session = await auth();
const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) return;
const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) return;
// Can't befriend yourself.
if (toId === fromId) return;
try {
// Guard against duplicate pending requests and already-existing friendships.
const [existingRequest, existingFriendship] = await Promise.all([
prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId },
select: { id: true },
}),
prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: fromId, userTwoId: toId },
{ userOneId: toId, userTwoId: fromId },
],
},
select: { id: true },
}),
]);
if (existingRequest || existingFriendship) return;
await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId },
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
// Optional: revalidate the target profile if a username was supplied, purely
// to refresh any request-state UI rendered there.
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
let outcome: FriendRequestOutcome = "error";
try {
const session = await auth();
const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend:${fromId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) {
outcome = "invalid";
} else if (toId === fromId) {
outcome = "self";
} else {
// Guard against duplicate pending requests and already-existing friendships.
const [outgoingRequest, incomingRequest, existingFriendship] =
await Promise.all([
prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId },
select: { id: true },
}),
prisma.messengerFriendrequests.findFirst({
where: { userFromId: toId, userToId: fromId },
select: { id: true },
}),
prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: fromId, userTwoId: toId },
{ userOneId: toId, userTwoId: fromId },
],
},
select: { id: true },
}),
]);
if (existingFriendship) {
outcome = "already_friends";
} else if (outgoingRequest) {
outcome = "already_pending";
} else if (incomingRequest) {
// They already asked you — respond from Messages instead of
// creating a duplicate reverse row.
outcome = "incoming_pending";
} else {
await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId },
});
outcome = "sent";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (username) revalidatePath(`/u/${username}`);
revalidatePath("/messages");
profileRedirect(username, outcome);
}
/**
@@ -78,67 +154,88 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
* plus the opening post stored as the first comment (guilds_forums_comments).
* We create both in a transaction so the thread always has its first post, then
* stamp posts_count = 1 to match the emulator's bookkeeping.
*
* Errors redirect back to the new-thread form with ?error=; success redirects
* to the forum with ?posted=1.
*/
export async function postThread(formData: FormData): Promise<void> {
const session = await auth();
const openerId = Number(session?.user?.id);
if (!Number.isInteger(openerId) || openerId <= 0) return;
const guildId = Number(formData.get("guildId"));
if (!Number.isInteger(guildId) || guildId <= 0) return;
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, SUBJECT_MAX);
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!subject || !message) return;
const now = Math.floor(Date.now() / 1000);
let outcome: ThreadOutcome = "error";
try {
// Confirm the guild exists (and has a forum) before opening a thread.
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true },
});
if (!guild) return;
const session = await auth();
const openerId = Number(session?.user?.id);
if (!Number.isInteger(openerId) || openerId <= 0) {
redirect("/login");
}
await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({
data: {
guildId,
openerId,
subject,
postsCount: 1,
createdAt: now,
updatedAt: now,
state: 0,
pinned: 0,
locked: 0,
adminId: 0,
},
select: { id: true },
});
if (!Number.isInteger(guildId) || guildId <= 0) {
outcome = "invalid";
} else {
await clientIp();
if (!(await rateLimit(`forum:${openerId}`, 3, 60_000)).ok) {
outcome = "ratelimit";
} else {
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, SUBJECT_MAX);
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!subject || !message) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId: openerId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
});
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true },
});
if (!guild) {
outcome = "not_found";
} else {
await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({
data: {
guildId,
openerId,
subject,
postsCount: 1,
createdAt: now,
updatedAt: now,
state: 0,
pinned: 0,
locked: 0,
adminId: 0,
},
select: { id: true },
});
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId: openerId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
});
});
outcome = "posted";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath(`/guilds/${guildId}/forum`);
if (Number.isInteger(guildId) && guildId > 0) {
revalidatePath(`/guilds/${guildId}/forum`);
}
threadRedirect(guildId, outcome);
}