feat: jwt cache, redis health, help-ticket admin, and write rate limits
Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
3bb96eb6f3
commit
968ca15c27
23 files changed
+1344
-205
No files matched your search
@@ -0,0 +1,136 @@
|
||||
"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 { logAudit } from "@/lib/services/audit";
|
||||
|
||||
const ticketIdField = z
|
||||
.union([z.string(), z.number(), z.bigint()])
|
||||
.transform((v) => BigInt(String(v)));
|
||||
|
||||
const replyHelpCenterTicketSchema = z.object({
|
||||
ticketId: ticketIdField,
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
const helpCenterTicketIdSchema = z.object({
|
||||
ticketId: ticketIdField,
|
||||
});
|
||||
|
||||
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||
const id = String(ticketId);
|
||||
revalidatePath("/admin/help-tickets");
|
||||
revalidatePath(`/admin/help-tickets/${id}`);
|
||||
revalidatePath("/help/tickets");
|
||||
revalidatePath(`/help/tickets/${id}`);
|
||||
}
|
||||
|
||||
export const replyHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
const now = new Date();
|
||||
const staffId = Number(ctx.session.user.id);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.websiteHelpCenterTicketReplies.create({
|
||||
data: {
|
||||
ticketId,
|
||||
userId: staffId,
|
||||
content: ctx.data.content.trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
}),
|
||||
prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { updatedAt: now },
|
||||
}),
|
||||
]);
|
||||
|
||||
logAudit({
|
||||
userId: staffId,
|
||||
action: "help_center_ticket_reply",
|
||||
target: "WebsiteHelpCenterTickets",
|
||||
targetId: Number(ticketId),
|
||||
});
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const closeHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket.open) throw new ActionError("Ticket is already closed");
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: Number(ctx.session.user.id),
|
||||
action: "help_center_ticket_close",
|
||||
target: "WebsiteHelpCenterTickets",
|
||||
targetId: Number(ticketId),
|
||||
before: { open: true },
|
||||
after: { open: false },
|
||||
});
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const reopenHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, open: true },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.open) throw new ActionError("Ticket is already open");
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: true, updatedAt: now },
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: Number(ctx.session.user.id),
|
||||
action: "help_center_ticket_reopen",
|
||||
target: "WebsiteHelpCenterTickets",
|
||||
targetId: Number(ticketId),
|
||||
before: { open: false },
|
||||
after: { open: true },
|
||||
});
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -4,13 +4,19 @@ 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";
|
||||
|
||||
// The reaction set the UI offers. The action rejects anything outside this list
|
||||
// so the website_article_reactions.reaction VARCHAR(50) only ever holds known
|
||||
// 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";
|
||||
type ReactionOutcome =
|
||||
| "updated"
|
||||
| "invalid"
|
||||
| "not_found"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function reactionRedirect(slug: string, outcome: ReactionOutcome): never {
|
||||
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
|
||||
@@ -50,58 +56,63 @@ export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) {
|
||||
outcome = "invalid";
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`article-reaction:${userId}`, 30, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) {
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const articleId = BigInt(articleIdRaw);
|
||||
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) {
|
||||
outcome = "not_found";
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
slug = article.slug;
|
||||
const articleId = BigInt(articleIdRaw);
|
||||
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
|
||||
if (existing?.active) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
if (!article) {
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
slug = article.slug;
|
||||
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing?.active) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
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";
|
||||
}
|
||||
outcome = "updated";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
-86
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp } from "@/lib/rate-limit";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
| "not_enough"
|
||||
| "no_referrals"
|
||||
| "bad_config"
|
||||
| "ratelimit"
|
||||
| "error" = "error";
|
||||
|
||||
try {
|
||||
@@ -53,97 +54,105 @@ export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referrals_needed" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referral_reward_amount" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: {
|
||||
key: {
|
||||
in: ["referral_reward_currency_type", "referral_reward_currency"],
|
||||
},
|
||||
},
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||
const currency = (currencyRaw?.value ?? "diamonds")
|
||||
.trim()
|
||||
.toLowerCase() as CurrencyName;
|
||||
|
||||
// The user's referral tally lives in user_referrals (one row per user).
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, referralsTotal: true },
|
||||
orderBy: { id: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
|
||||
if (!referrals || total <= 0) {
|
||||
outcome = "no_referrals";
|
||||
} else if (total < needed) {
|
||||
outcome = "not_enough";
|
||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||
// Misconfigured reward — keep it conservative and grant nothing.
|
||||
outcome = "bad_config";
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`referral-claim:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
await prisma.userReferrals.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { decrement: needed } },
|
||||
});
|
||||
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
await prisma.userReferrals
|
||||
.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { increment: needed } },
|
||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referrals_needed" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => {});
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referral_reward_amount" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: {
|
||||
key: {
|
||||
in: [
|
||||
"referral_reward_currency_type",
|
||||
"referral_reward_currency",
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
await prisma.claimedReferralLogs
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||
const currency = (currencyRaw?.value ?? "diamonds")
|
||||
.trim()
|
||||
.toLowerCase() as CurrencyName;
|
||||
|
||||
// The user's referral tally lives in user_referrals (one row per user).
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, referralsTotal: true },
|
||||
orderBy: { id: "desc" },
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
.catch(() => null);
|
||||
|
||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
|
||||
if (!referrals || total <= 0) {
|
||||
outcome = "no_referrals";
|
||||
} else if (total < needed) {
|
||||
outcome = "not_enough";
|
||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||
// Misconfigured reward — keep it conservative and grant nothing.
|
||||
outcome = "bad_config";
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
await prisma.userReferrals.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { decrement: needed } },
|
||||
});
|
||||
|
||||
outcome = "claimed";
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
await prisma.userReferrals
|
||||
.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { increment: needed } },
|
||||
})
|
||||
.catch(() => {});
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
|
||||
await prisma.claimedReferralLogs
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
});
|
||||
|
||||
outcome = "claimed";
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||
|
||||
+13
-1
@@ -1,11 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
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.
|
||||
* revoke personal access tokens, then end the current browser session too.
|
||||
*/
|
||||
export async function signOutEverywhere(): Promise<void> {
|
||||
const session = await auth();
|
||||
@@ -20,6 +22,16 @@ export async function signOutEverywhere(): Promise<void> {
|
||||
where: { id: userId },
|
||||
data: { websiteJwtVersion: { increment: 1 } },
|
||||
});
|
||||
await invalidateJwtVersionCache(userId);
|
||||
} catch {
|
||||
/* still continue */
|
||||
}
|
||||
|
||||
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
|
||||
try {
|
||||
await prisma.personalAccessTokens.deleteMany({
|
||||
where: personalTokenScope(userId),
|
||||
});
|
||||
} catch {
|
||||
/* still sign out locally */
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user