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