feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ed7db6e048
commit
803e8f36c1
47 files changed
+2793
-358
No files matched your search
@@ -2,10 +2,13 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
@@ -70,7 +73,35 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
|
||||
export async function deleteAd(formData: FormData): Promise<void> {
|
||||
const deleteAdInput = z.object({
|
||||
id: z
|
||||
.union([z.string(), z.number(), z.bigint()])
|
||||
.transform((v) => BigInt(String(v))),
|
||||
});
|
||||
|
||||
export const deleteAd = adminAction(
|
||||
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
|
||||
async (ctx) => {
|
||||
const id = ctx.data.id;
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
} catch {
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: Number(ctx.session.user.id),
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
revalidatePath("/admin/ads");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
|
||||
export async function deleteAdForm(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
+112
-64
@@ -1,98 +1,146 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// AtomCMS validates the application body with `min:10`. Mirror that floor and
|
||||
// cap the write defensively (column is TEXT, but we keep applications sane).
|
||||
const CONTENT_MIN = 10;
|
||||
const CONTENT_MAX = 5000;
|
||||
|
||||
type ApplyOutcome =
|
||||
| "submitted"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "duplicate"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function staffRedirect(outcome: ApplyOutcome): never {
|
||||
if (outcome === "submitted") redirect("/apply/staff?submitted=1");
|
||||
redirect(`/apply/staff?error=${outcome}`);
|
||||
}
|
||||
|
||||
function teamRedirect(outcome: ApplyOutcome): never {
|
||||
if (outcome === "submitted") redirect("/apply/team?submitted=1");
|
||||
redirect(`/apply/team?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a STAFF application for an open position.
|
||||
*
|
||||
* Faithful to AtomCMS's StaffApplicationsController@store:
|
||||
* - the applicant (user_id) is re-read from the session via auth() and is
|
||||
* NEVER trusted from the submitted FormData;
|
||||
* - rank_id is the open position's permission id (the rank being applied for);
|
||||
* - a user may only apply once per rank (idempotency guard);
|
||||
* - content must be at least 10 characters.
|
||||
*/
|
||||
export async function applyStaff(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// rank_id comes from the open position's permission_id (an Int in the schema).
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
let outcome: ApplyOutcome = "error";
|
||||
|
||||
try {
|
||||
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`apply-staff:${userId}`, 3, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
outcome = "duplicate";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/apply/staff");
|
||||
staffRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a TEAM application.
|
||||
*
|
||||
* The Prisma `website_staff_applications` slice has no dedicated team column, so
|
||||
* (per the conversion brief) team applications REUSE the staff-applications
|
||||
* table with the team acting as the rank: rank_id carries the team id. The
|
||||
* applicant is re-read from the session, never trusted from the form, and a user
|
||||
* may only apply once per team.
|
||||
*/
|
||||
export async function applyTeam(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
|
||||
// id is the application's rank flag.
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
let outcome: ApplyOutcome = "error";
|
||||
|
||||
try {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`apply-team:${userId}`, 3, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
outcome = "duplicate";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/apply/team");
|
||||
teamRedirect(outcome);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -9,91 +10,106 @@ import { prisma } from "@/lib/prisma";
|
||||
// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx.
|
||||
const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
|
||||
|
||||
type ReactionOutcome = "updated" | "invalid" | "not_found" | "error";
|
||||
|
||||
function reactionRedirect(slug: string, outcome: ReactionOutcome): never {
|
||||
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
|
||||
if (outcome === "updated") redirect(`${path}?reaction=1`);
|
||||
redirect(`${path}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the SIGNED-IN user's reaction on a news article.
|
||||
*
|
||||
* The voter id is read from the session (re-fetched via auth()), never from the
|
||||
* submitted FormData, so a crafted form cannot vote as another account. A user
|
||||
* has at most one ACTIVE reaction per article:
|
||||
* - clicking the reaction they already have active -> deactivates it (un-vote)
|
||||
* - clicking a different reaction -> that reaction becomes active and any other
|
||||
* reaction rows for this user/article are deactivated
|
||||
* - first-ever reaction of a type -> a new active row is created
|
||||
*
|
||||
* Rows are toggled (active flag) rather than deleted so a user's history of
|
||||
* reaction types is preserved. website_article_reactions has no composite
|
||||
* unique key, so we resolve the existing row with findFirst rather than upsert.
|
||||
*/
|
||||
export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
const slugHint = String(formData.get("slug") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
let outcome: ReactionOutcome = "error";
|
||||
let slug = slugHint;
|
||||
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
let slug: string | null;
|
||||
try {
|
||||
// Confirm the article exists (and grab its slug for revalidation).
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) return;
|
||||
slug = article.slug;
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// The user's current row for THIS reaction on THIS article, if any.
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
|
||||
if (existing?.active) {
|
||||
// Already reacting with this exact reaction -> un-vote (deactivate it).
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
// Switching to (or first-time picking) this reaction: clear any other
|
||||
// active reaction by this user on this article, then activate this one.
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
const articleId = BigInt(articleIdRaw);
|
||||
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) {
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
slug = article.slug;
|
||||
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
|
||||
if (existing?.active) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
outcome = "updated";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
if (slug && outcome !== "error") revalidatePath(`/news/${slug}`);
|
||||
reactionRedirect(slug, outcome);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -55,10 +56,14 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||
|
||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
||||
let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail";
|
||||
let boughtCode = "";
|
||||
|
||||
try {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
@@ -115,6 +120,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
outcome = "fail";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
|
||||
const ticketSchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
@@ -20,11 +22,32 @@ type TicketOutcome =
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type TicketDetailOutcome =
|
||||
| "replied"
|
||||
| "closed"
|
||||
| "invalid"
|
||||
| "not_found"
|
||||
| "closed_ticket"
|
||||
| "moderated"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function ticketsRedirect(outcome: TicketOutcome): never {
|
||||
if (outcome === "created") redirect("/help/tickets?created=1");
|
||||
redirect(`/help/tickets?error=${outcome}`);
|
||||
}
|
||||
|
||||
function ticketDetailRedirect(
|
||||
ticketId: bigint | null,
|
||||
outcome: TicketDetailOutcome,
|
||||
): never {
|
||||
if (!ticketId) redirect("/help/tickets?error=invalid");
|
||||
const base = `/help/tickets/${ticketId}`;
|
||||
if (outcome === "replied") redirect(`${base}?replied=1`);
|
||||
if (outcome === "closed") redirect(`${base}?closed=1`);
|
||||
redirect(`${base}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
@@ -99,3 +122,149 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
revalidatePath("/help/tickets");
|
||||
ticketsRedirect(outcome);
|
||||
}
|
||||
|
||||
const replyContentSchema = z.object({
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
||||
let outcome: TicketDetailOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket-reply:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const raw = {
|
||||
content: String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
};
|
||||
const parsed = replyContentSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true },
|
||||
});
|
||||
|
||||
if (!ticket || ticket.userId !== userId) {
|
||||
outcome = "not_found";
|
||||
} else if (!ticket.open) {
|
||||
outcome = "closed_ticket";
|
||||
} else {
|
||||
let moderated = false;
|
||||
try {
|
||||
await moderateOrThrow(parsed.data.content);
|
||||
} catch {
|
||||
moderated = true;
|
||||
outcome = "moderated";
|
||||
}
|
||||
|
||||
if (!moderated) {
|
||||
const created = await prisma.$transaction((tx) =>
|
||||
createOwnedTicketReply(
|
||||
{
|
||||
findTicket: (id) =>
|
||||
tx.websiteHelpCenterTickets.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, open: true },
|
||||
}),
|
||||
createReply: (data) =>
|
||||
tx.websiteHelpCenterTicketReplies.create({
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
touchTicket: (id, updatedAt) =>
|
||||
tx.websiteHelpCenterTickets.update({
|
||||
where: { id },
|
||||
data: { updatedAt },
|
||||
select: { id: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
ticketId,
|
||||
userId,
|
||||
content: parsed.data.content,
|
||||
},
|
||||
),
|
||||
);
|
||||
outcome = created ? "replied" : "not_found";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (ticketId) revalidatePath(`/help/tickets/${ticketId}`);
|
||||
revalidatePath("/help/tickets");
|
||||
ticketDetailRedirect(ticketId, outcome);
|
||||
}
|
||||
|
||||
export async function closeHelpTicket(formData: FormData): Promise<void> {
|
||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
||||
let outcome: TicketDetailOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true },
|
||||
});
|
||||
|
||||
if (!ticket || ticket.userId !== userId) {
|
||||
outcome = "not_found";
|
||||
} else if (!ticket.open) {
|
||||
outcome = "closed_ticket";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
outcome = "closed";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (ticketId) revalidatePath(`/help/tickets/${ticketId}`);
|
||||
revalidatePath("/help/tickets");
|
||||
ticketDetailRedirect(ticketId, outcome);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type FriendOutcome =
|
||||
@@ -12,6 +13,15 @@ type FriendOutcome =
|
||||
| "not_found"
|
||||
| "unauthorized"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type SendOutcome =
|
||||
| "sent"
|
||||
| "not_friend"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "rate_limited"
|
||||
| "error";
|
||||
|
||||
function messagesRedirect(outcome: FriendOutcome): never {
|
||||
@@ -25,6 +35,11 @@ function friendsRedirect(outcome: FriendOutcome): never {
|
||||
redirect(`/friends?error=${outcome}`);
|
||||
}
|
||||
|
||||
function sendRedirect(outcome: SendOutcome): never {
|
||||
if (outcome === "sent") redirect("/messages?sent=1");
|
||||
redirect(`/messages?send_error=${outcome}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
@@ -52,6 +67,10 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
@@ -113,6 +132,7 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
|
||||
if (
|
||||
@@ -148,6 +168,10 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
@@ -167,6 +191,7 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
outcome = "declined";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
@@ -202,6 +227,10 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
@@ -230,6 +259,7 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
|
||||
outcome = deleted > 0 ? "removed" : "not_found";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
@@ -247,3 +277,81 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
revalidatePath("/messages");
|
||||
friendsRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an offline messenger message to a friend (Arcturus messenger_offline row).
|
||||
* Only confirmed friendships may receive messages; body is capped at 500 chars.
|
||||
*/
|
||||
export async function sendOfflineMessage(formData: FormData): Promise<void> {
|
||||
let outcome: SendOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!(await rateLimit(`offline-msg:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "rate_limited";
|
||||
} else {
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
const rawMessage = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const message = rawMessage.slice(0, 500);
|
||||
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
} else if (!message) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const friendship = await prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!friendship) {
|
||||
outcome = "not_friend";
|
||||
} else {
|
||||
const recipient = await prisma.user.findUnique({
|
||||
where: { id: friendId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!recipient) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await prisma.messengerOffline.create({
|
||||
data: {
|
||||
userId: friendId,
|
||||
userFromId: meId,
|
||||
message,
|
||||
sendedOn: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
outcome = "sent";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
sendRedirect(outcome);
|
||||
}
|
||||
+68
-39
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// Column bounds from prisma/schema.prisma (radio_applications):
|
||||
// real_name VARCHAR(255); the rest are TEXT. age is an INT.
|
||||
@@ -10,6 +12,27 @@ const NAME_MAX = 255;
|
||||
const TEXT_MAX = 5000;
|
||||
const STYLE_MAX = 5000;
|
||||
|
||||
type ApplyOutcome =
|
||||
| "submitted"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function applyRedirect(outcome: ApplyOutcome): never {
|
||||
if (outcome === "submitted") redirect("/radio/apply?submitted=1");
|
||||
redirect(`/radio/apply?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
function str(form: FormData, key: string, max: number): string {
|
||||
return String(form.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -19,50 +42,56 @@ function str(form: FormData, key: string, max: number): string {
|
||||
|
||||
/**
|
||||
* Submit a radio DJ application.
|
||||
*
|
||||
* The applicant (userId) is ALWAYS re-read from the session via auth() and is
|
||||
* never taken from the submitted FormData, so a crafted form cannot file an
|
||||
* application on behalf of another account. radio_applications.user_id is an
|
||||
* UnsignedBigInt, hence the BigInt() coercion.
|
||||
*/
|
||||
export async function applyDj(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
let outcome: ApplyOutcome = "error";
|
||||
|
||||
const realName = str(formData, "realName", NAME_MAX);
|
||||
const availability = str(formData, "availability", TEXT_MAX);
|
||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||
const experience = str(formData, "experience", TEXT_MAX);
|
||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||
|
||||
const ageRaw = Number(formData.get("age"));
|
||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||
|
||||
// Required fields per the schema (NOT NULL): real_name, age, availability,
|
||||
// motivation. experience + music_style are nullable.
|
||||
if (!realName || !availability || !motivation || age <= 0) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable or duplicate — fail soft; nothing to persist.
|
||||
return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`radio-apply:${userId}`, 2, 300_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const realName = str(formData, "realName", NAME_MAX);
|
||||
const availability = str(formData, "availability", TEXT_MAX);
|
||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||
const experience = str(formData, "experience", TEXT_MAX);
|
||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||
|
||||
const ageRaw = Number(formData.get("age"));
|
||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||
|
||||
if (!realName || !availability || !motivation || age <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/radio/apply");
|
||||
applyRedirect(outcome);
|
||||
}
|
||||
@@ -1,42 +1,80 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SONG_MAX = 255;
|
||||
const ARTIST_MAX = 255;
|
||||
|
||||
type RequestOutcome =
|
||||
| "posted"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function requestsRedirect(outcome: RequestOutcome): never {
|
||||
if (outcome === "posted") redirect("/radio/requests?posted=1");
|
||||
redirect(`/radio/requests?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitRequest(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
let outcome: RequestOutcome = "error";
|
||||
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioSongRequests.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`radio-req:${userId}`, 5, 30_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.radioSongRequests.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/radio/requests");
|
||||
requestsRedirect(outcome);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
|
||||
* then end the current browser session too.
|
||||
*/
|
||||
export async function signOutEverywhere(): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { websiteJwtVersion: { increment: 1 } },
|
||||
});
|
||||
} catch {
|
||||
/* still sign out locally */
|
||||
}
|
||||
|
||||
await signOut({ redirectTo: "/login?signedOutAll=1" });
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
|
||||
/**
|
||||
* Credits charged for a package row. AtomCMS stores `costs` in cents (USD display);
|
||||
* we mirror the top-up rate so $1.00 of list price costs creditsPerUnit() credits.
|
||||
*/
|
||||
function creditPriceFromCosts(costs: number): number {
|
||||
const rate = creditsPerUnit();
|
||||
const dollars = costs < 100 ? 1 : costs / 100;
|
||||
return Math.max(1, Math.floor(dollars * rate));
|
||||
}
|
||||
|
||||
function parseBadgeCodes(raw: string | null | undefined): string[] {
|
||||
if (!raw?.trim()) return [];
|
||||
return raw
|
||||
.split(/[,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && s.length <= 32);
|
||||
}
|
||||
|
||||
type BuyOutcome =
|
||||
| "bought"
|
||||
| "invalid"
|
||||
| "credits"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function shopRedirect(
|
||||
categoryId: string,
|
||||
outcome: BuyOutcome,
|
||||
articleName?: string,
|
||||
): never {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId) params.set("category", categoryId);
|
||||
if (outcome === "bought") {
|
||||
params.set("bought", "1");
|
||||
if (articleName) params.set("package", articleName);
|
||||
} else {
|
||||
params.set("error", outcome);
|
||||
}
|
||||
const qs = params.toString();
|
||||
redirect(qs ? `/shop?${qs}` : "/shop");
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase a website shop package with in-game credits (top up via /shop/topup first).
|
||||
* The buyer id is always taken from the session, never from FormData.
|
||||
*/
|
||||
export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
const categoryId = String(formData.get("categoryId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const safeCategory = /^\d+$/.test(categoryId) ? categoryId : "";
|
||||
|
||||
let outcome: BuyOutcome = "error";
|
||||
let packageName = "";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`shop-buy:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const rawId = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawId)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const article = await prisma.websiteShopArticles.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
costs: true,
|
||||
credits: true,
|
||||
duckets: true,
|
||||
diamonds: true,
|
||||
badges: true,
|
||||
giveRank: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
packageName = article.name;
|
||||
const price = creditPriceFromCosts(article.costs);
|
||||
|
||||
const buyer = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true, rank: true },
|
||||
});
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
const badgeCodes = parseBadgeCodes(article.badges);
|
||||
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
if (price > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
article.giveRank != null &&
|
||||
article.giveRank > 0 &&
|
||||
article.giveRank > buyer.rank
|
||||
) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { rank: article.giveRank },
|
||||
});
|
||||
}
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
"credits",
|
||||
article.credits,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
"duckets",
|
||||
article.duckets,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
"diamonds",
|
||||
article.diamonds,
|
||||
);
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
}
|
||||
|
||||
outcome = "bought";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/shop");
|
||||
shopRedirect(safeCategory, outcome, packageName || undefined);
|
||||
}
|
||||
@@ -29,6 +29,14 @@ type ThreadOutcome =
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type ReplyOutcome =
|
||||
| "replied"
|
||||
| "invalid"
|
||||
| "not_found"
|
||||
| "locked"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function profileRedirect(
|
||||
username: string,
|
||||
outcome: FriendRequestOutcome,
|
||||
@@ -47,6 +55,24 @@ function threadRedirect(guildId: number, outcome: ThreadOutcome): never {
|
||||
redirect(`${base}/new?error=${outcome}`);
|
||||
}
|
||||
|
||||
function threadReplyRedirect(
|
||||
guildId: number,
|
||||
threadId: number,
|
||||
outcome: ReplyOutcome,
|
||||
): never {
|
||||
if (
|
||||
!Number.isInteger(guildId) ||
|
||||
guildId <= 0 ||
|
||||
!Number.isInteger(threadId) ||
|
||||
threadId <= 0
|
||||
) {
|
||||
redirect("/guilds");
|
||||
}
|
||||
const base = `/guilds/${guildId}/forum/${threadId}`;
|
||||
if (outcome === "replied") redirect(`${base}?replied=1`);
|
||||
redirect(`${base}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
@@ -239,3 +265,102 @@ export async function postThread(formData: FormData): Promise<void> {
|
||||
}
|
||||
threadRedirect(guildId, outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to an existing guild forum thread.
|
||||
*
|
||||
* The AUTHOR (user_id) is re-read from the session via auth() and is never
|
||||
* trusted from the submitted FormData. guildId, threadId, and message come
|
||||
* from the form.
|
||||
*
|
||||
* Appends a row to guilds_forums_comments and bumps the thread's posts_count
|
||||
* and updated_at to match emulator bookkeeping. Locked threads reject replies.
|
||||
*/
|
||||
export async function replyToThread(formData: FormData): Promise<void> {
|
||||
const guildId = Number(formData.get("guildId"));
|
||||
const threadId = Number(formData.get("threadId"));
|
||||
let outcome: ReplyOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(guildId) ||
|
||||
guildId <= 0 ||
|
||||
!Number.isInteger(threadId) ||
|
||||
threadId <= 0
|
||||
) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`forum-reply:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const thread = await prisma.guildsForumsThreads.findFirst({
|
||||
where: { id: threadId, guildId, state: 0 },
|
||||
select: {
|
||||
id: true,
|
||||
locked: true,
|
||||
postsCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!thread) {
|
||||
outcome = "not_found";
|
||||
} else if (thread.locked) {
|
||||
outcome = "locked";
|
||||
} else {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.guildsForumsComments.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
state: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.guildsForumsThreads.update({
|
||||
where: { id: thread.id },
|
||||
data: {
|
||||
postsCount: (thread.postsCount ?? 0) + 1,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
outcome = "replied";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isInteger(guildId) &&
|
||||
guildId > 0 &&
|
||||
Number.isInteger(threadId) &&
|
||||
threadId > 0
|
||||
) {
|
||||
revalidatePath(`/guilds/${guildId}/forum`);
|
||||
revalidatePath(`/guilds/${guildId}/forum/${threadId}`);
|
||||
}
|
||||
threadReplyRedirect(guildId, threadId, outcome);
|
||||
}
|
||||
+18
-3
@@ -17,7 +17,12 @@ import {
|
||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||
|
||||
export const createTicket = authAction(
|
||||
{ schema: createTicketSchema },
|
||||
{
|
||||
schema: createTicketSchema,
|
||||
rateLimitKey: "ticket-create",
|
||||
rateLimitMax: 5,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.create({
|
||||
data: {
|
||||
@@ -49,7 +54,12 @@ export const createTicket = authAction(
|
||||
);
|
||||
|
||||
export const userReplyTicket = authAction(
|
||||
{ schema: replyTicketSchema },
|
||||
{
|
||||
schema: replyTicketSchema,
|
||||
rateLimitKey: "ticket-reply",
|
||||
rateLimitMax: 10,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -82,7 +92,12 @@ export const userReplyTicket = authAction(
|
||||
);
|
||||
|
||||
export const closeTicketByUser = authAction(
|
||||
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
||||
{
|
||||
schema: replyTicketSchema.pick({ ticketId: true }),
|
||||
rateLimitKey: "ticket-close",
|
||||
rateLimitMax: 10,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
|
||||
@@ -40,6 +41,14 @@ export async function redeem(
|
||||
};
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`voucher-redeem:${userId}`, 5, 60_000)).ok) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "You're redeeming too fast. Please wait a moment and try again.",
|
||||
};
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
Reference in new issue
Block a user