feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
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:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
+191
-94
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user