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
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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<PrecheckResult> {
|
||||
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";
|
||||
}
|
||||
+203
-191
@@ -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<void> {
|
||||
await requirePermission(PERMS.RCON_EXECUTE);
|
||||
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||
|
||||
async function requireRconOk(ok: boolean): Promise<void> {
|
||||
if (!ok) throw new ActionError(RCON_FAIL);
|
||||
}
|
||||
|
||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||
export async function updateCatalog(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
},
|
||||
);
|
||||
@@ -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("[email protected]");
|
||||
expect(token).toMatch(/^\d+\.[a-f0-9]{64}$/);
|
||||
expect(await isValidVerificationToken("[email protected]", token)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects legacy forever-valid digests", async () => {
|
||||
const legacy = "a".repeat(64);
|
||||
expect(
|
||||
await isValidVerificationToken("[email protected]", legacy),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects expired tokens", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const token = await verificationToken("[email protected]");
|
||||
vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); // > 24h
|
||||
expect(await isValidVerificationToken("[email protected]", token)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends mail with a verify link", async () => {
|
||||
mockGetTranslations.mockResolvedValue(
|
||||
((key: string, values?: { hotel?: string }) => {
|
||||
const map: Record<string, string> = {
|
||||
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("[email protected]");
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
"[email protected]",
|
||||
expect.stringContaining("Verify your email"),
|
||||
expect.stringContaining("/verify?token="),
|
||||
);
|
||||
});
|
||||
});
|
||||
+60
-24
@@ -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<string> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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 = `
|
||||
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
||||
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
|
||||
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
|
||||
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
|
||||
<p>${escapeHtml(body)}</p>
|
||||
<p style="margin:1.25rem 0">
|
||||
<a href="${link}"
|
||||
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
|
||||
Verify email
|
||||
${escapeHtml(button)}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
|
||||
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
|
||||
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
return sendMail(normalised, subject, html);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
|
||||
+64
-2
@@ -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 });
|
||||
},
|
||||
);
|
||||
+73
-31
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
+80
-39
@@ -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<void> {
|
||||
// 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);
|
||||
}
|
||||
+217
-43
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<void> {
|
||||
.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)) {
|
||||
|
||||
+113
-2
@@ -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<number>();
|
||||
|
||||
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 });
|
||||
},
|
||||
);
|
||||
+75
-36
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
+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