Add dark mode, i18n, messenger/moderation/verify, admin CRUD parity
Web-tier features completing the AtomCMS→Next.js conversion (slice 2): UI/UX: - Dark mode: html.dark CSS-var overrides + ThemeSwitcher (localStorage, no-flash boot script) wired into the nav. - i18n (next-intl, cookie-based / no URL routing): en + it catalogs, request.ts, provider in root layout, LanguageSwitcher; shell (nav, header, footer) fully translated. URLs + access-guard unchanged. - globals.css: --muted/--border aliases used across admin pages. User features: - /messages: offline messages + friend-request accept (server action re-reads session, two directional rows, idempotent). - Email verification: signed-token /verify route + sendVerification wired into register (best-effort, never blocks signup). - Article reactions: toggle UI on news/[slug] + server action. - Content moderation service (website_wordfilter + optional OpenAI moderations, fail-open) wired into article comments + guestbook. Admin CRUD parity (Filament replacement): - /admin/shop (+ new/[id]) packages CRUD + read-only orders. - /admin/transactions read-only PayPal log. - /admin/permissions, /admin/tags, /admin/ads (+ new/[id]), /admin/help-questions (+ new/[id]), /admin/radio/history, /admin/users/[id]/edit. All gated by requireStaff + logStaffActivity. Verified: tsc 0, vitest 48/48, next build 0 (all routes incl. new admin CRUD + /messages + /verify).
This commit is contained in:
1 parent
22d53d0e9c
commit
7daeccb832
45 files changed
+3312
-84
No files matched your search
@@ -0,0 +1,84 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
// table; it only stores an image URL rendered in the site layout/widgets.
|
||||
|
||||
export async function createAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const ad = await prisma.websiteAds.create({
|
||||
data: { image, createdAt: now, updatedAt: now },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_create",
|
||||
description: `Created advertisement #${ad.id} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(ad.id),
|
||||
});
|
||||
} catch {
|
||||
// DB error — page re-renders unchanged.
|
||||
revalidatePath("/admin/ads");
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
|
||||
export async function updateAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.update({
|
||||
where: { id },
|
||||
data: { image, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_update",
|
||||
description: `Updated advertisement #${id} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
revalidatePath(`/admin/ads/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
|
||||
export async function deleteAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
||||
// is a titled content block with an optional image and call-to-action button.
|
||||
|
||||
function parsePosition(value: FormDataEntryValue | null): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
}
|
||||
|
||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "").trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
|
||||
|
||||
try {
|
||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||
data: {
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_create",
|
||||
description: `Created help-center entry #${entry.id} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(entry.id),
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — re-render unchanged.
|
||||
revalidatePath("/admin/help-questions");
|
||||
return;
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
}
|
||||
|
||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "").trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
|
||||
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_update",
|
||||
description: `Updated help-center entry #${id} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found, unique collision, or DB error — ignore.
|
||||
revalidatePath(`/admin/help-questions/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
}
|
||||
|
||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_delete",
|
||||
description: `Deleted help-center entry #${id}`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// website_permissions (model WebsitePermissions) is the CMS-owned rank-permission
|
||||
// mapping AtomCMS exposes in admin: a key/value(/comment) row per permission.
|
||||
// Editable columns on the table are exactly: key (unique), value, comment.
|
||||
// id is BigInt and created_at/updated_at are managed here — no other columns
|
||||
// exist, so there are no extra staff flags to toggle.
|
||||
|
||||
export async function createPermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const key = String(formData.get("key") ?? "").trim().slice(0, 255);
|
||||
const value = String(formData.get("value") ?? "").trim().slice(0, 255);
|
||||
const comment = String(formData.get("comment") ?? "").trim().slice(0, 255);
|
||||
if (!key) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.websitePermissions.upsert({
|
||||
where: { key },
|
||||
update: { value: value || null, comment: comment || null, updatedAt: now },
|
||||
create: {
|
||||
key,
|
||||
value: value || null,
|
||||
comment: comment || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_create",
|
||||
description: `Saved permission "${key}" = "${value}"`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch {
|
||||
// ignore (e.g. constraint failure) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
}
|
||||
|
||||
export async function updatePermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!raw) return;
|
||||
const id = BigInt(raw);
|
||||
const key = String(formData.get("key") ?? "").trim().slice(0, 255);
|
||||
const value = String(formData.get("value") ?? "").trim().slice(0, 255);
|
||||
const comment = String(formData.get("comment") ?? "").trim().slice(0, 255);
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await prisma.websitePermissions.update({
|
||||
where: { id },
|
||||
data: {
|
||||
key,
|
||||
value: value || null,
|
||||
comment: comment || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_update",
|
||||
description: `Updated permission #${raw} ("${key}" = "${value}")`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch {
|
||||
// ignore (e.g. duplicate key) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
}
|
||||
|
||||
export async function deletePermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "");
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const deleted = await prisma.websitePermissions.delete({
|
||||
where: { id: BigInt(raw) },
|
||||
select: { key: true },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_delete",
|
||||
description: `Deleted permission #${raw} ("${deleted.key}")`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch {
|
||||
// ignore (e.g. already removed)
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Website store packages (website_shop_articles). This CMS-owned table backs
|
||||
// the public store; rows here are the buyable packages, not orders. The closest
|
||||
// "orders" record is website_paypal_transactions, exposed read-only by the page.
|
||||
|
||||
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
|
||||
function optUInt(formData: FormData, key: string): number | null {
|
||||
const raw = String(formData.get(key) ?? "").trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/** Parse a required non-negative UnsignedInt, falling back to 0. */
|
||||
function reqUInt(formData: FormData, key: string): number {
|
||||
const n = optUInt(formData, key);
|
||||
return n ?? 0;
|
||||
}
|
||||
|
||||
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.websiteShopArticles.create({
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "").trim().slice(0, 255),
|
||||
icon: String(formData.get("icon") ?? "").trim().slice(0, 255),
|
||||
color: String(formData.get("color") ?? "").trim().slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_create",
|
||||
description: `Created shop package "${name}" (${created.costs} costs)`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const raw = String(formData.get("id") ?? "").trim();
|
||||
if (!raw) return;
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "").trim().slice(0, 255),
|
||||
icon: String(formData.get("icon") ?? "").trim().slice(0, 255),
|
||||
color: String(formData.get("color") ?? "").trim().slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_update",
|
||||
description: `Updated shop package #${id} ("${name}")`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath(`/admin/shop/${id}`);
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const raw = String(formData.get("id") ?? "").trim();
|
||||
if (!raw) return;
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_delete",
|
||||
description: `Deleted shop package #${id}`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { requireStaff } from '@/lib/admin/guard';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { logStaffActivity } from '@/lib/services/staff-activity';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === 'string' ? raw : '';
|
||||
}
|
||||
|
||||
/** Normalise a hex-ish colour into the 10-char background_color column. */
|
||||
function normaliseColor(raw: string): string {
|
||||
const v = raw.trim().slice(0, 10);
|
||||
return v || '#888888';
|
||||
}
|
||||
|
||||
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
|
||||
|
||||
export async function createTag(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = str(formData.get('name')).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const backgroundColor = normaliseColor(str(formData.get('backgroundColor')));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const created = await prisma.tags.create({
|
||||
data: { name, backgroundColor, createdAt: now, updatedAt: now },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: 'tag_create',
|
||||
description: `Created tag "${name}" (#${created.id})`,
|
||||
targetType: 'tag',
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable or duplicate.
|
||||
}
|
||||
revalidatePath('/admin/tags');
|
||||
}
|
||||
|
||||
export async function updateTag(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
|
||||
const name = str(formData.get('name')).trim().slice(0, 255);
|
||||
const backgroundColor = normaliseColor(str(formData.get('backgroundColor')));
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.tags.update({
|
||||
where: { id },
|
||||
data: { name, backgroundColor, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: 'tag_update',
|
||||
description: `Updated tag #${id} → "${name}"`,
|
||||
targetType: 'tag',
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath('/admin/tags');
|
||||
}
|
||||
|
||||
export async function deleteTag(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get('id'));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
// Remove the tag and any taggable links pointing at it.
|
||||
await prisma.$transaction([
|
||||
prisma.taggables.deleteMany({ where: { tagId: id } }),
|
||||
prisma.tags.delete({ where: { id } }),
|
||||
]);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: 'tag_delete',
|
||||
description: `Deleted tag #${id}`,
|
||||
targetType: 'tag',
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath('/admin/tags');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// users_currency.type values for the non-credits currencies (mirror send-currency.ts).
|
||||
// Credits live on users.credits; pixels/points live on the users row too;
|
||||
// duckets/diamonds live in users_currency keyed by (user_id, type).
|
||||
const DUCKETS_TYPE = 0;
|
||||
const DIAMONDS_TYPE = 5;
|
||||
|
||||
function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
||||
if (value == null) return null;
|
||||
const raw = String(value).trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const i = Math.trunc(n);
|
||||
return i < min ? min : i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the SAFE website-managed fields of a users row (and the duckets/diamonds
|
||||
* balances in users_currency). Never touches the password. Re-reads the staff
|
||||
* user from the session and logs the action. emulator-owned users.id is Int.
|
||||
*/
|
||||
export async function updateUser(formData: FormData): Promise<void> {
|
||||
// Never trust the client: re-check staff inside the action.
|
||||
const staff = await requireStaff();
|
||||
|
||||
const userId = Number(formData.get("id"));
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
// users row — only existing, safe columns.
|
||||
const mailRaw = String(formData.get("mail") ?? "").trim();
|
||||
const motto = String(formData.get("motto") ?? "").slice(0, 127);
|
||||
const look = String(formData.get("look") ?? "").slice(0, 256);
|
||||
const rank = toInt(formData.get("rank"), 1);
|
||||
const credits = toInt(formData.get("credits"), 0);
|
||||
const pixels = toInt(formData.get("pixels"), 0);
|
||||
const points = toInt(formData.get("points"), 0);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
|
||||
motto,
|
||||
look,
|
||||
...(rank != null ? { rank } : {}),
|
||||
...(credits != null ? { credits } : {}),
|
||||
...(pixels != null ? { pixels } : {}),
|
||||
...(points != null ? { points } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// users_currency — set exact balances for duckets / diamonds.
|
||||
const duckets = toInt(formData.get("duckets"), 0);
|
||||
const diamonds = toInt(formData.get("diamonds"), 0);
|
||||
if (duckets != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DUCKETS_TYPE } },
|
||||
update: { amount: duckets },
|
||||
create: { userId, type: DUCKETS_TYPE, amount: duckets },
|
||||
});
|
||||
}
|
||||
if (diamonds != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
|
||||
});
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_edit",
|
||||
description: `Edited account fields of user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
revalidatePath(`/admin/users/${userId}/edit`);
|
||||
redirect(`/admin/users/${userId}`);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
|
||||
const COMMENT_MAX = 255;
|
||||
@@ -23,6 +24,9 @@ export async function postComment(formData: FormData): Promise<void> {
|
||||
const comment = String(formData.get("comment") ?? "").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") ?? "").trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// 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"]);
|
||||
|
||||
/**
|
||||
* 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") ?? "").trim().toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
let slug: string | null = 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;
|
||||
|
||||
// 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 },
|
||||
});
|
||||
} 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 },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
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.
|
||||
//
|
||||
// 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).
|
||||
|
||||
/** Secret mixed into the digest. Falls back to AUTH_SECRET, then a constant. */
|
||||
function verifySecret(): string {
|
||||
return env.APP_KEY || env.AUTH_SECRET || "atom-cms-verify";
|
||||
}
|
||||
|
||||
/** 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()}`).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time check that `token` matches the expected digest for `email`.
|
||||
* Returns false on any length/format 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);
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(token.toLowerCase(), "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the verification link + email and send it. No-ops gracefully when SMTP
|
||||
* is unconfigured (sendMail returns false). `userId` is accepted for a faithful
|
||||
* call signature, but the stateless token only needs the email.
|
||||
*/
|
||||
export async function sendVerification(userId: number, email: string): Promise<boolean> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
if (!normalised) return false;
|
||||
|
||||
const token = await verificationToken(normalised);
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(
|
||||
normalised,
|
||||
)}`;
|
||||
|
||||
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||
|
||||
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>
|
||||
<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
|
||||
</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;word-break:break-all">${link}</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// `userId` referenced so a faithful caller signature isn't flagged unused.
|
||||
void userId;
|
||||
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
|
||||
const MESSAGE_MAX = 255;
|
||||
@@ -27,6 +28,9 @@ export async function postGuestbook(formData: FormData): Promise<void> {
|
||||
const message = String(formData.get("message") ?? "").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") ?? "").trim();
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Accept a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
* The ACCEPTER is re-read from the session via auth() and is never trusted from
|
||||
* the submitted FormData. Only the request id comes from the form, and the
|
||||
* request is only honoured when its target (user_to_id) is the session user —
|
||||
* so a crafted form cannot accept a request addressed to someone else.
|
||||
*
|
||||
* Arcturus/AtomCMS stores friendships as TWO directional rows in
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
|
||||
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 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 },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
revalidatePath("/friends");
|
||||
}
|
||||
+11
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { sendVerification } from "@/actions/email-verify";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -39,7 +40,7 @@ export async function register(formData: FormData): Promise<void> {
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
await prisma.user.create({
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
@@ -49,7 +50,16 @@ export async function register(formData: FormData): Promise<void> {
|
||||
ipCurrent: ip,
|
||||
look: DEFAULT_LOOK,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Fire the verification email. Best-effort: a mail/SMTP failure must not
|
||||
// abort a successful registration, so swallow its errors here.
|
||||
try {
|
||||
await sendVerification(created.id, mail);
|
||||
} catch {
|
||||
// No-op: account is created; user can request a new link later.
|
||||
}
|
||||
} catch {
|
||||
error = "Could not create the account (is the username unique?)";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user