style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+55 -52
View File
File diff suppressed because it is too large. Load diff
+65 -52
View File
File diff suppressed because it is too large. Load diff
+94 -98
View File
File diff suppressed because it is too large. Load diff
+2890 -7575
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
}, },
}; };
+7 -7
View File
@@ -6,11 +6,11 @@ import { defineConfig, env } from "prisma/config";
// live with the Arcturus emulator. CMS-only schema changes go in // live with the Arcturus emulator. CMS-only schema changes go in
// prisma/migrations/*.sql (idempotent) applied via `pnpm db:migrate`. // prisma/migrations/*.sql (idempotent) applied via `pnpm db:migrate`.
export default defineConfig({ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
}, },
datasource: { datasource: {
url: env("DATABASE_URL"), url: env("DATABASE_URL"),
}, },
}); });
+123 -110
View File
File diff suppressed because it is too large. Load diff
+13 -11
View File
@@ -1,12 +1,14 @@
(function () { (() => {
try { try {
var s = localStorage.getItem("theme"); var s = localStorage.getItem("theme");
var dd = document.querySelector('meta[name="theme-default-dark"]'); var dd = document.querySelector('meta[name="theme-default-dark"]');
var defaultDark = dd ? dd.getAttribute("content") === "true" : false; var defaultDark = dd ? dd.getAttribute("content") === "true" : false;
if (s === "dark" || (!s && defaultDark)) document.documentElement.classList.add("dark"); if (s === "dark" || (!s && defaultDark))
var nc = localStorage.getItem("navbarColor"), document.documentElement.classList.add("dark");
nt = localStorage.getItem("navbarTextColor"); var nc = localStorage.getItem("navbarColor"),
if (nc) document.documentElement.style.setProperty("--color-navbar", nc); nt = localStorage.getItem("navbarTextColor");
if (nt) document.documentElement.style.setProperty("--color-navbar-text", nt); if (nc) document.documentElement.style.setProperty("--color-navbar", nc);
} catch (e) {} if (nt)
document.documentElement.style.setProperty("--color-navbar-text", nt);
} catch (_e) {}
})(); })();
+49 -42
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -3,10 +3,11 @@ import { findMissingLocalImports } from "../src/lib/local-imports";
const missing = await findMissingLocalImports(process.cwd()); const missing = await findMissingLocalImports(process.cwd());
if (missing.length === 0) { if (missing.length === 0) {
console.log("No unresolved local imports."); console.log("No unresolved local imports.");
process.exit(0); process.exit(0);
} }
for (const item of missing) console.error(`${item.importer}: ${item.specifier}`); for (const item of missing)
console.error(`${item.importer}: ${item.specifier}`);
console.error(`${missing.length} unresolved local import(s).`); console.error(`${missing.length} unresolved local import(s).`);
process.exitCode = 1; process.exitCode = 1;
+8 -8
View File
@@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest";
import { mysqlConnectionUrl } from "./db-url"; import { mysqlConnectionUrl } from "./db-url";
describe("mysqlConnectionUrl", () => { describe("mysqlConnectionUrl", () => {
it("removes Prisma-only pool options before passing the URL to MySQL2", () => { it("removes Prisma-only pool options before passing the URL to MySQL2", () => {
const result = mysqlConnectionUrl( const result = mysqlConnectionUrl(
"mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4", "mysql://user:pass@localhost:3306/cms?connection_limit=20&pool_timeout=30&charset=utf8mb4",
); );
expect(result).not.toContain("connection_limit"); expect(result).not.toContain("connection_limit");
expect(result).not.toContain("pool_timeout"); expect(result).not.toContain("pool_timeout");
expect(result).toContain("charset=utf8mb4"); expect(result).toContain("charset=utf8mb4");
}); });
}); });
+8 -4
View File
@@ -1,7 +1,11 @@
const MYSQL2_UNSUPPORTED_OPTIONS = ["connection_limit", "pool_timeout"] as const; const MYSQL2_UNSUPPORTED_OPTIONS = [
"connection_limit",
"pool_timeout",
] as const;
export function mysqlConnectionUrl(value: string): string { export function mysqlConnectionUrl(value: string): string {
const url = new URL(value); const url = new URL(value);
for (const option of MYSQL2_UNSUPPORTED_OPTIONS) url.searchParams.delete(option); for (const option of MYSQL2_UNSUPPORTED_OPTIONS)
return url.toString(); url.searchParams.delete(option);
return url.toString();
} }
+71 -57
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -3,10 +3,13 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("radio columns migration", () => { describe("radio columns migration", () => {
it("adds every column idempotently for partially migrated databases", () => { it("adds every column idempotently for partially migrated databases", () => {
const sql = readFileSync(resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"), "utf8"); const sql = readFileSync(
const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? []; resolve("prisma/migrations/0009_radio_contests_giveaways_columns.sql"),
expect(additions).toEqual([]); "utf8",
expect(sql).toContain("ADD COLUMN IF NOT EXISTS `title`"); );
}); const additions = sql.match(/ADD COLUMN(?! IF NOT EXISTS)/gi) ?? [];
expect(additions).toEqual([]);
expect(sql).toContain("ADD COLUMN IF NOT EXISTS `title`");
});
}); });
+12 -12
View File
@@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest";
import { splitSqlStatements } from "./sql-statements"; import { splitSqlStatements } from "./sql-statements";
describe("splitSqlStatements", () => { describe("splitSqlStatements", () => {
it("ignores semicolons inside line comments", () => { it("ignores semicolons inside line comments", () => {
const sql = [ const sql = [
"-- Existing installs have this; new installs need it.", "-- Existing installs have this; new installs need it.",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL;", "ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL;",
"-- next statement", "-- next statement",
"CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY);", "CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY);",
].join("\n"); ].join("\n");
expect(splitSqlStatements(sql)).toEqual([ expect(splitSqlStatements(sql)).toEqual([
"ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL", "ALTER TABLE users ADD COLUMN IF NOT EXISTS example TEXT NULL",
"CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY)", "CREATE TABLE IF NOT EXISTS example_table (id INT PRIMARY KEY)",
]); ]);
}); });
}); });
+7 -5
View File
@@ -1,8 +1,10 @@
export function splitSqlStatements(sql: string): string[] { export function splitSqlStatements(sql: string): string[] {
const withoutComments = sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*--.*$/gm, ""); const withoutComments = sql
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*--.*$/gm, "");
return withoutComments return withoutComments
.split(";") .split(";")
.map((statement) => statement.trim()) .map((statement) => statement.trim())
.filter(Boolean); .filter(Boolean);
} }
+7 -7
View File
@@ -1,9 +1,9 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"noEmit": true "noEmit": true
}, },
"include": ["./**/*.ts"] "include": ["./**/*.ts"]
} }
File diff suppressed because it is too large. Load diff
+13 -13
View File
@@ -11,20 +11,20 @@ import { rcon } from "@/lib/services/rcon";
* `message` payload. Staff-gated; the message is trimmed/bounded before send. * `message` payload. Staff-gated; the message is trimmed/bounded before send.
*/ */
export async function sendHotelAlert(formData: FormData): Promise<void> { export async function sendHotelAlert(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const message = String(formData.get("message") ?? "") const message = String(formData.get("message") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 1000); .slice(0, 1000);
if (!message) return; if (!message) return;
try { try {
await rcon.send("hotelalert", { message }); await rcon.send("hotelalert", { message });
} catch { } catch {
// Best-effort delivery (dead socket / emulator offline) — never 500 the // Best-effort delivery (dead socket / emulator offline) — never 500 the
// admin page. The emulator writes its own alert_logs row on receipt. // admin page. The emulator writes its own alert_logs row on receipt.
} }
revalidatePath("/admin/alerts"); revalidatePath("/admin/alerts");
} }
+10 -10
View File
@@ -2,19 +2,19 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> { export async function dismissApplication(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
try { try {
await prisma.websiteStaffApplications.delete({ where: { id } }); await prisma.websiteStaffApplications.delete({ where: { id } });
} catch { } catch {
// already gone / no DB — nothing to do // already gone / no DB — nothing to do
} }
revalidatePath("/admin/applications"); revalidatePath("/admin/applications");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+31 -31
View File
@@ -6,39 +6,39 @@ import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> { export async function giveBadge(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 32); .slice(0, 32);
if (!(userId > 0) || code.length === 0) return; if (!(userId > 0) || code.length === 0) return;
// Fire the emulator command so the badge appears live for online users. // Fire the emulator command so the badge appears live for online users.
await rcon.giveBadge(userId, code); await rcon.giveBadge(userId, code);
// Persist the badge directly so it survives a relog / offline grant. // Persist the badge directly so it survives a relog / offline grant.
// users_badges has no unique (user_id, badge_code) constraint, so guard // users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves. // against duplicates and compute the next free slot ourselves.
try { try {
const existing = await prisma.usersBadges.findFirst({ const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode: code }, where: { userId, badgeCode: code },
select: { id: true }, select: { id: true },
}); });
if (!existing) { if (!existing) {
const max = await prisma.usersBadges.aggregate({ const max = await prisma.usersBadges.aggregate({
where: { userId }, where: { userId },
_max: { slotId: true }, _max: { slotId: true },
}); });
const slotId = (max._max.slotId ?? 0) + 1; const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({ await prisma.usersBadges.create({
data: { userId, slotId, badgeCode: code }, data: { userId, slotId, badgeCode: code },
}); });
} }
} catch { } catch {
// Best-effort: the RCON grant already succeeded for online users. // Best-effort: the RCON grant already succeeded for online users.
} }
revalidatePath("/admin/badges"); revalidatePath("/admin/badges");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30 -30
View File
@@ -10,37 +10,37 @@ import { prisma } from "@/lib/prisma";
// keys via upsert. We never migrate or drop them. // keys via upsert. We never migrate or drop them.
export async function updateEmulatorSetting(formData: FormData): Promise<void> { export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 100); .slice(0, 100);
const value = String(formData.get("value") ?? "") const value = String(formData.get("value") ?? "")
.normalize("NFC") .normalize("NFC")
.slice(0, 512); .slice(0, 512);
if (!key) return; if (!key) return;
await prisma.emulatorSettings.upsert({ await prisma.emulatorSettings.upsert({
where: { key }, where: { key },
update: { value }, update: { value },
create: { key, value }, create: { key, value },
}); });
revalidatePath("/admin/emulator"); revalidatePath("/admin/emulator");
} }
export async function updateEmulatorText(formData: FormData): Promise<void> { export async function updateEmulatorText(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 100); .slice(0, 100);
const value = String(formData.get("value") ?? "") const value = String(formData.get("value") ?? "")
.normalize("NFC") .normalize("NFC")
.slice(0, 4096); .slice(0, 4096);
if (!key) return; if (!key) return;
await prisma.emulatorTexts.upsert({ await prisma.emulatorTexts.upsert({
where: { key }, where: { key },
update: { value }, update: { value },
create: { key, value }, create: { key, value },
}); });
revalidatePath("/admin/emulator"); revalidatePath("/admin/emulator");
} }
+141 -139
View File
File diff suppressed because it is too large. Load diff
+33 -31
View File
@@ -9,45 +9,47 @@ import { prisma } from "@/lib/prisma";
* string). Mirrors AtomCMS' housekeeping permission management. * string). Mirrors AtomCMS' housekeeping permission management.
*/ */
export async function upsertPermission(formData: FormData): Promise<void> { export async function upsertPermission(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const permission = String(formData.get("permission") ?? "") const permission = String(formData.get("permission") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const minRank = Number(formData.get("minRank")); const minRank = Number(formData.get("minRank"));
const descriptionRaw = String(formData.get("description") ?? "") const descriptionRaw = String(formData.get("description") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const description = descriptionRaw.length > 0 ? descriptionRaw : null; const description = descriptionRaw.length > 0 ? descriptionRaw : null;
if (!permission || !Number.isFinite(minRank) || minRank < 0) return; if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
try { try {
await prisma.websiteHousekeepingPermissions.upsert({ await prisma.websiteHousekeepingPermissions.upsert({
where: { permission }, where: { permission },
update: { minRank, description }, update: { minRank, description },
create: { permission, minRank, description }, create: { permission, minRank, description },
}); });
} catch { } catch {
// Swallow: duplicate/constraint issues shouldn't crash the action. // Swallow: duplicate/constraint issues shouldn't crash the action.
} }
revalidatePath("/admin/housekeeping"); revalidatePath("/admin/housekeeping");
} }
export async function deletePermission(formData: FormData): Promise<void> { export async function deletePermission(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const raw = String(formData.get("id") ?? "").normalize("NFC"); const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return; if (!raw) return;
try { try {
await prisma.websiteHousekeepingPermissions.delete({ where: { id: BigInt(raw) } }); await prisma.websiteHousekeepingPermissions.delete({
} catch { where: { id: BigInt(raw) },
// Already gone / invalid id. });
} } catch {
// Already gone / invalid id.
}
revalidatePath("/admin/housekeeping"); revalidatePath("/admin/housekeeping");
} }
+39 -39
View File
@@ -5,58 +5,58 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
function parseIp(formData: FormData): string { function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "") return String(formData.get("ipAddress") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
} }
function parseAsn(formData: FormData): string | null { function parseAsn(formData: FormData): string | null {
const asn = String(formData.get("asn") ?? "") const asn = String(formData.get("asn") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
return asn || null; return asn || null;
} }
export async function addWhitelist(formData: FormData): Promise<void> { export async function addWhitelist(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const ipAddress = parseIp(formData); const ipAddress = parseIp(formData);
if (!ipAddress) return; if (!ipAddress) return;
const asn = parseAsn(formData); const asn = parseAsn(formData);
await prisma.websiteIpWhitelist.create({ await prisma.websiteIpWhitelist.create({
data: { ipAddress, asn, whitelistAsn: asn != null }, data: { ipAddress, asn, whitelistAsn: asn != null },
}); });
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
export async function deleteWhitelist(formData: FormData): Promise<void> { export async function deleteWhitelist(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const raw = String(formData.get("id") ?? "") const raw = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!raw) return; if (!raw) return;
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } }); await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
export async function addBlacklist(formData: FormData): Promise<void> { export async function addBlacklist(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const ipAddress = parseIp(formData); const ipAddress = parseIp(formData);
if (!ipAddress) return; if (!ipAddress) return;
const asn = parseAsn(formData); const asn = parseAsn(formData);
await prisma.websiteIpBlacklist.create({ await prisma.websiteIpBlacklist.create({
data: { ipAddress, asn, blacklistAsn: asn != null }, data: { ipAddress, asn, blacklistAsn: asn != null },
}); });
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
export async function deleteBlacklist(formData: FormData): Promise<void> { export async function deleteBlacklist(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const raw = String(formData.get("id") ?? "") const raw = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!raw) return; if (!raw) return;
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } }); await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
revalidatePath("/admin/ip"); revalidatePath("/admin/ip");
} }
+29 -26
View File
@@ -18,41 +18,44 @@ const KEY_MESSAGE = "maintenance_message";
const KEY_MIN_RANK = "min_maintenance_login_rank"; const KEY_MIN_RANK = "min_maintenance_login_rank";
const COMMENTS: Record<string, string> = { const COMMENTS: Record<string, string> = {
[KEY_ENABLED]: "Determines whether maintenance is enabled or not", [KEY_ENABLED]: "Determines whether maintenance is enabled or not",
[KEY_MESSAGE]: "The maintenance message displayed to users while maintenance is activated", [KEY_MESSAGE]:
[KEY_MIN_RANK]: "The minimum rank required to login to the hotel during maintenance", "The maintenance message displayed to users while maintenance is activated",
[KEY_MIN_RANK]:
"The minimum rank required to login to the hotel during maintenance",
}; };
async function upsertSetting(key: string, value: string): Promise<void> { async function upsertSetting(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
where: { key }, where: { key },
update: { value }, update: { value },
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values // eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
create: { key, value, comment: COMMENTS[key] ?? null }, create: { key, value, comment: COMMENTS[key] ?? null },
}); });
} }
export async function saveMaintenance(formData: FormData): Promise<void> { export async function saveMaintenance(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the // Checkbox: present only when ticked. Normalise to the '1'/'0' string the
// emulator/Laravel side expects. // emulator/Laravel side expects.
const enabled = formData.get("enabled") != null ? "1" : "0"; const enabled = formData.get("enabled") != null ? "1" : "0";
const message = String(formData.get("message") ?? "").normalize("NFC"); const message = String(formData.get("message") ?? "").normalize("NFC");
// Coerce the rank to a non-negative integer; fall back to AtomCMS's default // Coerce the rank to a non-negative integer; fall back to AtomCMS's default
// of 5 when the field is blank or garbage. // of 5 when the field is blank or garbage.
const rawRank = String(formData.get("min_rank") ?? "") const rawRank = String(formData.get("min_rank") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const parsedRank = Number.parseInt(rawRank, 10); const parsedRank = Number.parseInt(rawRank, 10);
const minRank = Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5; const minRank =
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
await upsertSetting(KEY_ENABLED, enabled); await upsertSetting(KEY_ENABLED, enabled);
await upsertSetting(KEY_MESSAGE, message); await upsertSetting(KEY_MESSAGE, message);
await upsertSetting(KEY_MIN_RANK, String(minRank)); await upsertSetting(KEY_MIN_RANK, String(minRank));
siteSettings.reload(); siteSettings.reload();
revalidatePath("/admin/maintenance"); revalidatePath("/admin/maintenance");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9 -9
View File
@@ -5,15 +5,15 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function deletePhoto(formData: FormData): Promise<void> { export async function deletePhoto(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (!(id > 0)) return; if (!(id > 0)) return;
try { try {
await prisma.cameraWeb.delete({ where: { id } }); await prisma.cameraWeb.delete({ where: { id } });
} catch { } catch {
// Record may have already been removed; ignore. // Record may have already been removed; ignore.
} }
revalidatePath("/admin/photos"); revalidatePath("/admin/photos");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+24 -24
View File
@@ -2,18 +2,18 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */ /** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null { function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== "string" || raw.trim() === "") return null; if (typeof raw !== "string" || raw.trim() === "") return null;
try { try {
const id = BigInt(raw.trim()); const id = BigInt(raw.trim());
return id > 0n ? id : null; return id > 0n ? id : null;
} catch { } catch {
return null; return null;
} }
} }
/** /**
@@ -22,22 +22,22 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
* moderation route. Fails soft if the row is already gone. * moderation route. Fails soft if the row is already gone.
*/ */
export async function deleteShout(formData: FormData): Promise<void> { export async function deleteShout(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
try { try {
await prisma.radioShouts.delete({ where: { id } }); await prisma.radioShouts.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio.shout.delete", action: "radio.shout.delete",
description: `Deleted radio shout #${id}`, description: `Deleted radio shout #${id}`,
targetType: "radio_shout", targetType: "radio_shout",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Row may already be gone; ignore so the action does not throw. // Row may already be gone; ignore so the action does not throw.
} }
revalidatePath("/admin/radio/moderation"); revalidatePath("/admin/radio/moderation");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+35 -35
View File
@@ -6,45 +6,45 @@ import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export async function updateSetting(formData: FormData): Promise<void> { export async function updateSetting(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const value = String(formData.get("value") ?? "").normalize("NFC"); const value = String(formData.get("value") ?? "").normalize("NFC");
if (!key) return; if (!key) return;
await prisma.websiteSetting.update({ where: { key }, data: { value } }); await prisma.websiteSetting.update({ where: { key }, data: { value } });
siteSettings.reload(); siteSettings.reload();
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
} }
export async function createSetting(formData: FormData): Promise<void> { export async function createSetting(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const value = String(formData.get("value") ?? "").normalize("NFC"); const value = String(formData.get("value") ?? "").normalize("NFC");
const comment = String(formData.get("comment") ?? "") const comment = String(formData.get("comment") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!key) return; if (!key) return;
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
where: { key }, where: { key },
update: { value }, update: { value },
create: { key, value, comment: comment || null }, create: { key, value, comment: comment || null },
}); });
siteSettings.reload(); siteSettings.reload();
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
} }
export async function deleteSetting(formData: FormData): Promise<void> { export async function deleteSetting(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const key = String(formData.get("key") ?? "") const key = String(formData.get("key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!key) return; if (!key) return;
await prisma.websiteSetting.delete({ where: { key } }); await prisma.websiteSetting.delete({ where: { key } });
siteSettings.reload(); siteSettings.reload();
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
} }
+140 -137
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+33 -33
View File
@@ -5,46 +5,46 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function createTeam(formData: FormData): Promise<void> { export async function createTeam(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const rankName = String(formData.get("rankName") ?? "") const rankName = String(formData.get("rankName") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!rankName) return; if (!rankName) return;
const badge = String(formData.get("badge") ?? "") const badge = String(formData.get("badge") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const jobDescription = String(formData.get("jobDescription") ?? "") const jobDescription = String(formData.get("jobDescription") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const staffColor = const staffColor =
String(formData.get("staffColor") ?? "") String(formData.get("staffColor") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() || "#327fa8"; .trim() || "#327fa8";
const hiddenRank = formData.get("hiddenRank") === "on"; const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date(); const now = new Date();
await prisma.websiteTeams.create({ await prisma.websiteTeams.create({
data: { data: {
rankName: rankName.slice(0, 255), rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null, badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null, jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255), staffColor: staffColor.slice(0, 255),
hiddenRank, hiddenRank,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
} }
export async function deleteTeam(formData: FormData): Promise<void> { export async function deleteTeam(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
await prisma.websiteTeams.delete({ where: { id } }); await prisma.websiteTeams.delete({ where: { id } });
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
} }
+158 -157
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+23 -23
View File
@@ -6,32 +6,32 @@ import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
export async function addWord(formData: FormData): Promise<void> { export async function addWord(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const word = String(formData.get("word") ?? "") const word = String(formData.get("word") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!word) return; if (!word) return;
try { try {
await prisma.websiteWordfilter.create({ data: { word } }); await prisma.websiteWordfilter.create({ data: { word } });
await rcon.updateWordFilter(); await rcon.updateWordFilter();
} catch { } catch {
// ignore (e.g. duplicate word) — page re-renders current state // ignore (e.g. duplicate word) — page re-renders current state
} }
revalidatePath("/admin/wordfilter"); revalidatePath("/admin/wordfilter");
} }
export async function deleteWord(formData: FormData): Promise<void> { export async function deleteWord(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const raw = String(formData.get("id") ?? "").normalize("NFC"); const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return; if (!raw) return;
try { try {
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } }); await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
await rcon.updateWordFilter(); await rcon.updateWordFilter();
} catch { } catch {
// ignore (e.g. already removed) // ignore (e.g. already removed)
} }
revalidatePath("/admin/wordfilter"); revalidatePath("/admin/wordfilter");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+37 -29
View File
@@ -1,9 +1,9 @@
"use server"; "use server";
import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password"; import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { env } from "@/env";
export type PrecheckResult = "ok" | "invalid" | "twofactor"; export type PrecheckResult = "ok" | "invalid" | "twofactor";
@@ -11,36 +11,44 @@ export type PrecheckResult = "ok" | "invalid" | "twofactor";
* Validates username+password WITHOUT creating a session, and reports whether a * Validates username+password WITHOUT creating a session, and reports whether a
* TOTP code is still required. Lets the login form do the two-step 2FA flow. * TOTP code is still required. Lets the login form do the two-step 2FA flow.
*/ */
export async function precheckLogin(username: string, password: string): Promise<PrecheckResult> { export async function precheckLogin(
const u = String(username ?? "") username: string,
.normalize("NFC") password: string,
.trim(); ): Promise<PrecheckResult> {
const p = String(password ?? ""); const u = String(username ?? "")
if (!u || !p) return "invalid"; .normalize("NFC")
.trim();
const p = String(password ?? "");
if (!u || !p) return "invalid";
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok) return "invalid"; if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok)
return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null; let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
try { try {
user = await prisma.user.findUnique({ user = await prisma.user.findUnique({
where: { username: u }, where: { username: u },
select: { password: true, twoFactorConfirmedAt: true }, select: { password: true, twoFactorConfirmedAt: true },
}); });
} catch { } catch {
return "invalid"; return "invalid";
} }
if (!user) { if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check. // Prevent timing-based enumeration: always run a dummy hash check.
await checkLogin(p, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", { await checkLogin(
convertPasswords: false, p,
}); "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
return "invalid"; {
} convertPasswords: false,
},
);
return "invalid";
}
const res = await checkLogin(p, user.password, { const res = await checkLogin(p, user.password, {
convertPasswords: env.CONVERT_PASSWORDS, convertPasswords: env.CONVERT_PASSWORDS,
}); });
if (!res.valid) return "invalid"; if (!res.valid) return "invalid";
return user.twoFactorConfirmedAt ? "twofactor" : "ok"; return user.twoFactorConfirmedAt ? "twofactor" : "ok";
} }
+32 -21
View File
@@ -5,27 +5,38 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function getBadgeData({ code }: { code: string }) { export async function getBadgeData({ code }: { code: string }) {
await requireStaff(); await requireStaff();
const badge = await prisma.websiteBadges.findUnique({ const badge = await prisma.websiteBadges.findUnique({
where: { badgeKey: code }, where: { badgeKey: code },
select: { badgeName: true, badgeDescription: true }, select: { badgeName: true, badgeDescription: true },
}); });
if (!badge) return { ok: false as const, data: null }; if (!badge) return { ok: false as const, data: null };
return { ok: true as const, data: { name: badge.badgeName, desc: badge.badgeDescription } }; return {
ok: true as const,
data: { name: badge.badgeName, desc: badge.badgeDescription },
};
} }
export async function updateBadge({ code, name, desc }: { code: string; name: string; desc: string }) { export async function updateBadge({
await requireStaff(); code,
await prisma.websiteBadges.upsert({ name,
where: { badgeKey: code }, desc,
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() }, }: {
create: { code: string;
badgeKey: code, name: string;
badgeName: name, desc: string;
badgeDescription: desc, }) {
createdAt: new Date(), await requireStaff();
updatedAt: new Date(), await prisma.websiteBadges.upsert({
}, where: { badgeKey: code },
}); update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
revalidatePath("/admin/import/badges"); create: {
badgeKey: code,
badgeName: name,
badgeDescription: desc,
createdAt: new Date(),
updatedAt: new Date(),
},
});
revalidatePath("/admin/import/badges");
} }
+49 -47
View File
File diff suppressed because it is too large. Load diff
+148 -125
View File
File diff suppressed because it is too large. Load diff
+108 -95
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+134 -125
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34 -21
View File
@@ -16,28 +16,36 @@ import { siteSettings } from "@/lib/services/site-settings";
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */ /** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string { function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET; const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret) throw new Error("APP_KEY or AUTH_SECRET must be set for email verification"); if (!secret)
return secret; throw new Error(
"APP_KEY or AUTH_SECRET must be set for email verification",
);
return secret;
} }
/** Compute the verification token for an email (lowercased + trimmed). */ /** Compute the verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> { export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase(); const normalised = email.trim().toLowerCase();
return createHash("sha256").update(`${normalised}|${verifySecret()}`).digest("hex"); return createHash("sha256")
.update(`${normalised}|${verifySecret()}`)
.digest("hex");
} }
/** /**
* Constant-time check that `token` matches the expected digest for `email`. * Constant-time check that `token` matches the expected digest for `email`.
* Returns false on any length/format mismatch rather than throwing. * Returns false on any length/format mismatch rather than throwing.
*/ */
export async function isValidVerificationToken(email: string, token: string): Promise<boolean> { export async function isValidVerificationToken(
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false; email: string,
const expected = await verificationToken(email); token: string,
const a = Buffer.from(expected, "utf8"); ): Promise<boolean> {
const b = Buffer.from(token.toLowerCase(), "utf8"); if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
if (a.length !== b.length) return false; const expected = await verificationToken(email);
return timingSafeEqual(a, b); const a = Buffer.from(expected, "utf8");
const b = Buffer.from(token.toLowerCase(), "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
} }
/** /**
@@ -45,16 +53,17 @@ export async function isValidVerificationToken(email: string, token: string): Pr
* is unconfigured (sendMail returns false). * is unconfigured (sendMail returns false).
*/ */
export async function sendVerification(email: string): Promise<boolean> { export async function sendVerification(email: string): Promise<boolean> {
const normalised = email.trim().toLowerCase(); const normalised = email.trim().toLowerCase();
if (!normalised) return false; if (!normalised) return false;
const token = await verificationToken(normalised); const token = await verificationToken(normalised);
const base = env.APP_URL.replace(/\/+$/, ""); const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`; const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME; const hotelName =
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
const html = ` const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a"> <div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">Verify your email</h2> <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>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
@@ -69,9 +78,13 @@ export async function sendVerification(email: string): Promise<boolean> {
</div> </div>
`.trim(); `.trim();
return sendMail(normalised, `Verify your email · ${hotelName}`, html); return sendMail(normalised, `Verify your email · ${hotelName}`, html);
} }
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
+20 -20
View File
@@ -9,31 +9,31 @@ import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
const saveEmulatorSettingsSchema = z.object({ const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()), settings: z.record(z.string(), z.string()),
}); });
export const saveEmulatorSettings = adminAction( export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema }, { permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => { async (ctx) => {
const entries = Object.entries(ctx.data.settings); const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) { for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({ await prisma.emulatorSettings.upsert({
where: { key }, where: { key },
update: { value: String(value) }, update: { value: String(value) },
create: { key, value: String(value) }, create: { key, value: String(value) },
}); });
} }
await rcon.updateConfig(); await rcon.updateConfig();
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "emulator_settings_update", action: "emulator_settings_update",
target: "EmulatorSettings", target: "EmulatorSettings",
after: ctx.data.settings, after: ctx.data.settings,
}); });
return actionOk(); return actionOk();
}, },
); );
+132 -122
View File
File diff suppressed because it is too large. Load diff
+32 -32
View File
@@ -18,42 +18,42 @@ const MESSAGE_MAX = 255;
* to revalidate the right page. * to revalidate the right page.
*/ */
export async function postGuestbook(formData: FormData): Promise<void> { export async function postGuestbook(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
const userId = Number(session?.user?.id); const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) return;
const profileId = Number(formData.get("profileId")); const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) return; if (!Number.isInteger(profileId) || profileId <= 0) return;
const message = String(formData.get("message") ?? "") const message = String(formData.get("message") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, MESSAGE_MAX); .slice(0, MESSAGE_MAX);
if (!message) return; if (!message) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open). // Block filtered/AI-flagged content before it touches the DB (fail-open).
if (!(await isAllowed(message)).ok) return; if (!(await isAllowed(message)).ok) return;
// Optional: used only to revalidate the correct profile route. // Optional: used only to revalidate the correct profile route.
const username = String(formData.get("username") ?? "") const username = String(formData.get("username") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const now = new Date(); const now = new Date();
try { try {
await prisma.websiteUserGuestbooks.create({ await prisma.websiteUserGuestbooks.create({
data: { data: {
profileId, profileId,
userId, userId,
message, message,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { } catch {
// DB unavailable — fail soft; nothing to persist. // DB unavailable — fail soft; nothing to persist.
return; return;
} }
if (username) revalidatePath(`/u/${username}`); if (username) revalidatePath(`/u/${username}`);
} }
+40 -40
View File
@@ -4,57 +4,57 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { moderateOrThrow } from "@/lib/services/moderation";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
const ticketSchema = z.object({ const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255), title: z.string().min(1, "Title is required").max(255),
content: z.string().min(1, "Content is required").max(5000), content: z.string().min(1, "Content is required").max(5000),
}); });
export async function createTicket(formData: FormData): Promise<void> { export async function createTicket(formData: FormData): Promise<void> {
// Re-read the session user id server-side; never trust a form-supplied id. // Re-read the session user id server-side; never trust a form-supplied id.
const session = await auth(); const session = await auth();
const userId = Number(session?.user?.id); const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) return;
await clientIp(); await clientIp();
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return; if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
const raw = { const raw = {
title: String(formData.get("title") ?? "") title: String(formData.get("title") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
content: String(formData.get("content") ?? "") content: String(formData.get("content") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 5000), .slice(0, 5000),
}; };
const parsed = ticketSchema.safeParse(raw); const parsed = ticketSchema.safeParse(raw);
if (!parsed.success) return; if (!parsed.success) return;
const { title, content } = parsed.data; const { title, content } = parsed.data;
// Moderation check // Moderation check
try { try {
await moderateOrThrow(`${title} ${content}`); await moderateOrThrow(`${title} ${content}`);
} catch { } catch {
return; return;
} }
const now = new Date(); const now = new Date();
await prisma.websiteHelpCenterTickets.create({ await prisma.websiteHelpCenterTickets.create({
data: { data: {
userId, userId,
title, title,
content, content,
open: true, open: true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
revalidatePath("/help/tickets"); revalidatePath("/help/tickets");
} }
+27 -23
View File
@@ -4,29 +4,33 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function importBadgeFromRemote({ export async function importBadgeFromRemote({
code, code,
name, name,
description, description,
}: { }: {
code: string; code: string;
name: string; name: string;
description: string; description: string;
}) { }) {
await requireStaff(); await requireStaff();
try { try {
await prisma.websiteBadges.upsert({ await prisma.websiteBadges.upsert({
where: { badgeKey: code }, where: { badgeKey: code },
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() }, update: {
create: { badgeName: name,
badgeKey: code, badgeDescription: description,
badgeName: name, updatedAt: new Date(),
badgeDescription: description, },
createdAt: new Date(), create: {
updatedAt: new Date(), badgeKey: code,
}, badgeName: name,
}); badgeDescription: description,
return { ok: true as const }; createdAt: new Date(),
} catch { updatedAt: new Date(),
return { ok: false as const, error: "Failed to import badge" }; },
} });
return { ok: true as const };
} catch {
return { ok: false as const, error: "Failed to import badge" };
}
} }
+19 -8
View File
@@ -4,17 +4,28 @@ import { z } from "zod";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared"; import { actionOk } from "@/lib/safe-action-shared";
import { cleanupConvertedSwfs, deleteImportedItem } from "@/lib/services/furni-import"; import {
cleanupConvertedSwfs,
deleteImportedItem,
} from "@/lib/services/furni-import";
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => { export const cleanSwfFiles = adminAction(
const result = await cleanupConvertedSwfs(); { permission: PERMS.ASSETS_IMPORT },
return actionOk(result as unknown as Record<string, unknown>); async () => {
}); const result = await cleanupConvertedSwfs();
return actionOk(result as unknown as Record<string, unknown>);
},
);
const deleteSchema = z.object({ classname: z.string().trim().min(1) }); const deleteSchema = z.object({ classname: z.string().trim().min(1) });
export const deleteImportedFurni = adminAction( export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema }, { permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) => async (ctx) =>
actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>), actionOk(
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
string,
unknown
>,
),
); );
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+134 -130
View File
File diff suppressed because it is too large. Load diff
+45 -29
View File
@@ -4,38 +4,54 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export interface MultiAccountCluster { export interface MultiAccountCluster {
key: string; key: string;
label: string; label: string;
accountCount: number; accountCount: number;
accounts: Array<{ id: number; username: string; rank: number; online: string }>; accounts: Array<{
id: number;
username: string;
rank: number;
online: string;
}>;
} }
export async function detectMultiAccounts({ minAccounts, limit }: { minAccounts: number; limit: number }) { export async function detectMultiAccounts({
await requireStaff(); minAccounts,
const clusters: MultiAccountCluster[] = []; limit,
}: {
minAccounts: number;
limit: number;
}) {
await requireStaff();
const clusters: MultiAccountCluster[] = [];
const ipGroups = await prisma.user.groupBy({ const ipGroups = await prisma.user.groupBy({
by: ["ipCurrent"], by: ["ipCurrent"],
where: { ipCurrent: { not: "" } }, where: { ipCurrent: { not: "" } },
_count: { id: true }, _count: { id: true },
having: { id: { _count: { gte: minAccounts } } }, having: { id: { _count: { gte: minAccounts } } },
orderBy: { _count: { id: "desc" } }, orderBy: { _count: { id: "desc" } },
take: limit, take: limit,
}); });
for (const group of ipGroups) { for (const group of ipGroups) {
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { ipCurrent: group.ipCurrent }, where: { ipCurrent: group.ipCurrent },
select: { id: true, username: true, rank: true, online: true }, select: { id: true, username: true, rank: true, online: true },
orderBy: { id: "asc" }, orderBy: { id: "asc" },
}); });
clusters.push({ clusters.push({
key: group.ipCurrent, key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`, label: `IP: ${group.ipCurrent}`,
accountCount: group._count.id, accountCount: group._count.id,
accounts: users.map((u) => ({ id: u.id, username: u.username, rank: u.rank, online: u.online })), accounts: users.map((u) => ({
}); id: u.id,
} username: u.username,
rank: u.rank,
online: u.online,
})),
});
}
return { ok: true as const, data: { clusters } }; return { ok: true as const, data: { clusters } };
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+118 -107
View File
File diff suppressed because it is too large. Load diff
+71 -63
View File
File diff suppressed because it is too large. Load diff
+60 -60
View File
File diff suppressed because it is too large. Load diff
+38 -38
View File
@@ -11,10 +11,10 @@ const TEXT_MAX = 5000;
const STYLE_MAX = 5000; const STYLE_MAX = 5000;
function str(form: FormData, key: string, max: number): string { function str(form: FormData, key: string, max: number): string {
return String(form.get(key) ?? "") return String(form.get(key) ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, max); .slice(0, max);
} }
/** /**
@@ -26,43 +26,43 @@ function str(form: FormData, key: string, max: number): string {
* UnsignedBigInt, hence the BigInt() coercion. * UnsignedBigInt, hence the BigInt() coercion.
*/ */
export async function applyDj(formData: FormData): Promise<void> { export async function applyDj(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
const userId = Number(session?.user?.id); const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) return;
const realName = str(formData, "realName", NAME_MAX); const realName = str(formData, "realName", NAME_MAX);
const availability = str(formData, "availability", TEXT_MAX); const availability = str(formData, "availability", TEXT_MAX);
const motivation = str(formData, "motivation", TEXT_MAX); const motivation = str(formData, "motivation", TEXT_MAX);
const experience = str(formData, "experience", TEXT_MAX); const experience = str(formData, "experience", TEXT_MAX);
const musicStyle = str(formData, "musicStyle", STYLE_MAX); const musicStyle = str(formData, "musicStyle", STYLE_MAX);
const ageRaw = Number(formData.get("age")); const ageRaw = Number(formData.get("age"));
const age = Number.isInteger(ageRaw) ? ageRaw : 0; const age = Number.isInteger(ageRaw) ? ageRaw : 0;
// Required fields per the schema (NOT NULL): real_name, age, availability, // Required fields per the schema (NOT NULL): real_name, age, availability,
// motivation. experience + music_style are nullable. // motivation. experience + music_style are nullable.
if (!realName || !availability || !motivation || age <= 0) return; if (!realName || !availability || !motivation || age <= 0) return;
const now = new Date(); const now = new Date();
try { try {
await prisma.radioApplications.create({ await prisma.radioApplications.create({
data: { data: {
userId: BigInt(userId), userId: BigInt(userId),
realName, realName,
age, age,
availability, availability,
motivation, motivation,
experience: experience || null, experience: experience || null,
musicStyle: musicStyle || null, musicStyle: musicStyle || null,
status: "pending", status: "pending",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { } catch {
// DB unavailable or duplicate — fail soft; nothing to persist. // DB unavailable or duplicate — fail soft; nothing to persist.
return; return;
} }
revalidatePath("/radio/apply"); revalidatePath("/radio/apply");
} }
+28 -28
View File
@@ -8,35 +8,35 @@ const SONG_MAX = 255;
const ARTIST_MAX = 255; const ARTIST_MAX = 255;
export async function submitRequest(formData: FormData): Promise<void> { export async function submitRequest(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
const userId = Number(session?.user?.id); const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) return;
const songTitle = String(formData.get("songTitle") ?? "") const songTitle = String(formData.get("songTitle") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, SONG_MAX); .slice(0, SONG_MAX);
const artist = String(formData.get("artist") ?? "") const artist = String(formData.get("artist") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, ARTIST_MAX); .slice(0, ARTIST_MAX);
if (!songTitle && !artist) return; if (!songTitle && !artist) return;
const now = new Date(); const now = new Date();
try { try {
await prisma.radioSongRequests.create({ await prisma.radioSongRequests.create({
data: { data: {
userId: BigInt(userId), userId: BigInt(userId),
songTitle: songTitle || null, songTitle: songTitle || null,
artist: artist || null, artist: artist || null,
submittedAt: now, submittedAt: now,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { } catch {
return; return;
} }
revalidatePath("/radio/requests"); revalidatePath("/radio/requests");
} }
+37 -37
View File
@@ -4,11 +4,11 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { moderateOrThrow } from "@/lib/services/moderation";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
const shoutSchema = z.object({ const shoutSchema = z.object({
message: z.string().min(1, "Message is required").max(255), message: z.string().min(1, "Message is required").max(255),
}); });
/** /**
@@ -20,46 +20,46 @@ const shoutSchema = z.object({
* session id is widened to BigInt for the insert. * session id is widened to BigInt for the insert.
*/ */
export async function postShout(formData: FormData): Promise<void> { export async function postShout(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
const userId = Number(session?.user?.id); const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) return;
await clientIp(); await clientIp();
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return; if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
const raw = { const raw = {
message: String(formData.get("message") ?? "") message: String(formData.get("message") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
}; };
const parsed = shoutSchema.safeParse(raw); const parsed = shoutSchema.safeParse(raw);
if (!parsed.success) return; if (!parsed.success) return;
const { message } = parsed.data; const { message } = parsed.data;
// Moderation check // Moderation check
try { try {
await moderateOrThrow(message); await moderateOrThrow(message);
} catch { } catch {
return; return;
} }
const now = new Date(); const now = new Date();
try { try {
await prisma.radioShouts.create({ await prisma.radioShouts.create({
data: { data: {
userId: BigInt(userId), userId: BigInt(userId),
message, message,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { } catch {
// DB unavailable — fail soft; nothing to persist. // DB unavailable — fail soft; nothing to persist.
return; return;
} }
revalidatePath("/radio/shouts"); revalidatePath("/radio/shouts");
} }
+125 -103
View File
File diff suppressed because it is too large. Load diff
+110 -99
View File
File diff suppressed because it is too large. Load diff
+92 -70
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+92 -92
View File
File diff suppressed because it is too large. Load diff
+19 -19
View File
@@ -5,28 +5,28 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function deleteSoundtrack({ id }: { id: number }) { export async function deleteSoundtrack({ id }: { id: number }) {
await requireStaff(); await requireStaff();
await prisma.soundtracks.delete({ where: { id } }); await prisma.soundtracks.delete({ where: { id } });
revalidatePath("/admin/sounds"); revalidatePath("/admin/sounds");
} }
export async function updateSoundtrack({ export async function updateSoundtrack({
id, id,
name, name,
author, author,
track, track,
length, length,
}: { }: {
id: number; id: number;
name: string; name: string;
author: string; author: string;
track: string; track: string;
length: number; length: number;
}) { }) {
await requireStaff(); await requireStaff();
await prisma.soundtracks.update({ await prisma.soundtracks.update({
where: { id }, where: { id },
data: { name, author, track, length }, data: { name, author, track, length },
}); });
revalidatePath("/admin/sounds"); revalidatePath("/admin/sounds");
} }
+30 -24
View File
@@ -7,39 +7,45 @@ import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
const templateSchema = z.object({ const templateSchema = z.object({
title: z.string().min(1).max(255), title: z.string().min(1).max(255),
content: z.string().min(1), content: z.string().min(1),
category: z.string().max(50).optional().default("general"), category: z.string().max(50).optional().default("general"),
sortOrder: z.coerce.number().int().min(0).default(0), sortOrder: z.coerce.number().int().min(0).default(0),
}); });
export const createTemplate = adminAction( export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema }, { permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => { async (ctx) => {
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data }); const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
return actionOk({ id: tpl.id }); return actionOk({ id: tpl.id });
}, },
); );
const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() }); const updateTemplateInput = templateSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateTemplate = adminAction( export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } }); const existing = await prisma.websiteTicketTemplate.findUnique({
if (!existing) throw new ActionError("Template not found"); where: { id },
await prisma.websiteTicketTemplate.update({ where: { id }, data }); });
return actionOk({ id }); if (!existing) throw new ActionError("Template not found");
}, await prisma.websiteTicketTemplate.update({ where: { id }, data });
return actionOk({ id });
},
); );
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() }); const deleteTemplateInput = z.object({
id: z.coerce.number().int().positive(),
});
export const deleteTemplate = adminAction( export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => { async (ctx) => {
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } }); await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
return actionOk(); return actionOk();
}, },
); );
+178 -169
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+25 -20
View File
@@ -1,40 +1,45 @@
"use server"; "use server";
import { z } from "zod";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod";
import { actionOk, authAction } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { authAction, actionOk } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors";
const MOTTO_MAX = 127; const MOTTO_MAX = 127;
const mottoSchema = z.object({ const mottoSchema = z.object({
motto: z.string().max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`), motto: z
.string()
.max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
}); });
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => { const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
try { try {
await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } }); await prisma.user.update({
} catch { where: { id: ctx.session.user.id },
throw new DatabaseError("Failed to update motto"); data: { motto: ctx.data.motto },
} });
} catch {
throw new DatabaseError("Failed to update motto");
}
try { try {
await rcon.setMotto(ctx.session.user.id, ctx.data.motto); await rcon.setMotto(ctx.session.user.id, ctx.data.motto);
} catch { } catch {
// RCON is best-effort; the change is already persisted. // RCON is best-effort; the change is already persisted.
} }
revalidatePath("/settings"); revalidatePath("/settings");
return actionOk(); return actionOk();
}); });
export async function updateMotto(formData: FormData): Promise<void> { export async function updateMotto(formData: FormData): Promise<void> {
const motto = String(formData.get("motto") ?? "") const motto = String(formData.get("motto") ?? "")
.normalize("NFC") .normalize("NFC")
.slice(0, MOTTO_MAX); .slice(0, MOTTO_MAX);
await updateMottoAction({ motto }); await updateMottoAction({ motto });
} }
export { updateMottoAction }; export { updateMottoAction };
+319 -276
View File
File diff suppressed because it is too large. Load diff
+113 -88
View File
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -8,8 +8,8 @@ import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared"; import { actionOk } from "@/lib/safe-action-shared";
const toggleWatchSchema = z.object({ const toggleWatchSchema = z.object({
targetUserId: z.coerce.number().int().positive(), targetUserId: z.coerce.number().int().positive(),
reason: z.string().max(255).optional(), reason: z.string().max(255).optional(),
}); });
/** /**
@@ -19,25 +19,25 @@ const toggleWatchSchema = z.object({
* the UI can flip the badge without re-fetching. * the UI can flip the badge without re-fetching.
*/ */
export const toggleUserWatch = adminAction( export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema }, { permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => { async (ctx) => {
const staffId = ctx.session.user.id; const staffId = ctx.session.user.id;
const { targetUserId, reason } = ctx.data; const { targetUserId, reason } = ctx.data;
const existing = await prisma.userWatch.findUnique({ const existing = await prisma.userWatch.findUnique({
where: { staffId_targetUserId: { staffId, targetUserId } }, where: { staffId_targetUserId: { staffId, targetUserId } },
}); });
if (existing) { if (existing) {
await prisma.userWatch.delete({ where: { id: existing.id } }); await prisma.userWatch.delete({ where: { id: existing.id } });
revalidateTag(`user-watch:${staffId}`, { expire: 0 }); revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: false }); return actionOk({ watching: false });
} }
await prisma.userWatch.create({ await prisma.userWatch.create({
data: { staffId, targetUserId, reason: reason ?? "" }, data: { staffId, targetUserId, reason: reason ?? "" },
}); });
revalidateTag(`user-watch:${staffId}`, { expire: 0 }); revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: true }); return actionOk({ watching: true });
}, },
); );
File diff suppressed because it is too large. Load diff
Loaded 100 of 735 files, more files were not shown because too many files have changed in this diff. Show more