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
@@ -1,62 +1,65 @@
import js from "@eslint/js"; import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import nextPlugin from "@next/eslint-plugin-next"; import nextPlugin from "@next/eslint-plugin-next";
import security from "eslint-plugin-security";
import jsxA11y from "eslint-plugin-jsx-a11y";
import prettier from "eslint-config-prettier"; import prettier from "eslint-config-prettier";
import jsxA11y from "eslint-plugin-jsx-a11y";
import reactHooks from "eslint-plugin-react-hooks";
import security from "eslint-plugin-security";
import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
security.configs.recommended, security.configs.recommended,
prettier, prettier,
{ {
plugins: { plugins: {
"@next/next": nextPlugin, "@next/next": nextPlugin,
"react-hooks": reactHooks, "react-hooks": reactHooks,
"jsx-a11y": jsxA11y, "jsx-a11y": jsxA11y,
}, },
rules: { rules: {
...nextPlugin.configs.recommended.rules, ...nextPlugin.configs.recommended.rules,
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"no-console": "off", "no-console": "off",
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": [
"@typescript-eslint/no-explicit-any": "warn", "warn",
"@typescript-eslint/consistent-type-imports": "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
"@typescript-eslint/no-non-null-assertion": "warn", ],
"prefer-const": "error", "@typescript-eslint/no-explicit-any": "warn",
"no-var": "error", "@typescript-eslint/consistent-type-imports": "error",
eqeqeq: ["error", "always", { null: "ignore" }], "@typescript-eslint/no-non-null-assertion": "warn",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always", { null: "ignore" }],
"no-empty": ["warn", { allowEmptyCatch: true }], "no-empty": ["warn", { allowEmptyCatch: true }],
"no-useless-assignment": "off", "no-useless-assignment": "off",
"no-undef": "off", "no-undef": "off",
"security/detect-object-injection": "warn", "security/detect-object-injection": "warn",
"security/detect-non-literal-fs-filename": "warn", "security/detect-non-literal-fs-filename": "warn",
}, },
}, },
{ {
files: ["**/*.test.ts", "**/*.test.tsx", "scripts/**"], files: ["**/*.test.ts", "**/*.test.tsx", "scripts/**"],
rules: { rules: {
"security/detect-object-injection": "off", "security/detect-object-injection": "off",
"security/detect-non-literal-fs-filename": "off", "security/detect-non-literal-fs-filename": "off",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
}, },
}, },
{ {
ignores: [ ignores: [
".next/", ".next/",
"node_modules/", "node_modules/",
"src/generated/", "src/generated/",
"public/", "public/",
"prisma/migrations/", "prisma/migrations/",
"db_backup_*.sql", "db_backup_*.sql",
], ],
}, },
); );
+65 -52
View File
@@ -2,64 +2,77 @@ import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
const securityHeaders = [ const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" }, { key: "X-DNS-Prefetch-Control", value: "on" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, {
{ key: "X-Frame-Options", value: "DENY" }, key: "Strict-Transport-Security",
{ key: "X-Content-Type-Options", value: "nosniff" }, value: "max-age=63072000; includeSubDomains; preload",
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, },
{ { key: "X-Frame-Options", value: "DENY" },
key: "Permissions-Policy", { key: "X-Content-Type-Options", value: "nosniff" },
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
}, {
{ key: "Permissions-Policy",
key: "Content-Security-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
value: [ },
"default-src 'self'", {
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://static.cloudflareinsights.com", key: "Content-Security-Policy",
"style-src 'self' 'unsafe-inline'", value: [
"img-src 'self' data: blob: https:", "default-src 'self'",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://static.cloudflareinsights.com",
"connect-src 'self' https: wss:", "style-src 'self' 'unsafe-inline'",
"font-src 'self' data:", "img-src 'self' data: blob: https:",
"object-src 'none'", "frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
"base-uri 'self'", "connect-src 'self' https: wss:",
"form-action 'self'", "font-src 'self' data:",
].join("; "), "object-src 'none'",
}, "base-uri 'self'",
"form-action 'self'",
].join("; "),
},
]; ];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
turbopack: { root: import.meta.dirname }, turbopack: { root: import.meta.dirname },
serverExternalPackages: ["@prisma/adapter-mariadb", "mariadb", "@prisma/client", "lzma"], serverExternalPackages: [
"@prisma/adapter-mariadb",
"mariadb",
"@prisma/client",
"lzma",
],
// Compress responses with gzip // Compress responses with gzip
compress: true, compress: true,
// Cache pages longer in the router cache for faster back/forward navigation // Cache pages longer in the router cache for faster back/forward navigation
experimental: { experimental: {
staleTimes: { staleTimes: {
dynamic: 30, dynamic: 30,
static: 180, static: 180,
}, },
}, },
// Add caching headers for static assets // Add caching headers for static assets
async headers() { async headers() {
return [ return [
{ {
source: "/(.*)", source: "/(.*)",
headers: securityHeaders, headers: securityHeaders,
}, },
{ {
source: "/assets/(.*)", source: "/assets/(.*)",
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }], headers: [
}, {
{ key: "Cache-Control",
source: "/images/(.*)", value: "public, max-age=31536000, immutable",
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }], },
}, ],
]; },
}, {
source: "/images/(.*)",
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
},
];
},
}; };
// next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via // next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via
+94 -98
View File
@@ -1,100 +1,96 @@
{ {
"name": "atomcms-next", "name": "atomcms-next",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22" "node": ">=22"
}, },
"packageManager": "[email protected]", "packageManager": "[email protected]",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings 200", "biome:check": "biome check --write .",
"format": "prettier --write .", "biome:lint": "biome lint .",
"test": "vitest run", "biome:format": "biome format --write .",
"db:migrate": "tsx scripts/apply-migrations.ts", "test": "vitest run",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status", "db:migrate": "tsx scripts/apply-migrations.ts",
"jobs:worker": "tsx scripts/jobs-worker.ts" "db:migrate:status": "tsx scripts/apply-migrations.ts --status",
}, "jobs:worker": "tsx scripts/jobs-worker.ts"
"dependencies": { },
"@dnd-kit/core": "^6.3.1", "dependencies": {
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.4.0", "@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-mariadb": "^7.8.0", "@hookform/resolvers": "^5.4.0",
"@prisma/client": "^7.8.0", "@prisma/adapter-mariadb": "^7.8.0",
"@tanstack/react-virtual": "^3.14.5", "@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.2", "@tanstack/react-virtual": "^3.14.5",
"class-variance-authority": "^0.7.1", "bcryptjs": "^3.0.2",
"clsx": "^2.1.1", "class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1", "clsx": "^2.1.1",
"croner": "^10.0.1", "cmdk": "^1.1.1",
"hash-wasm": "^4.12.0", "croner": "^10.0.1",
"ioredis": "^5.11.1", "hash-wasm": "^4.12.0",
"jpeg-js": "^0.4.4", "ioredis": "^5.11.1",
"json5": "^2.2.3", "jpeg-js": "^0.4.4",
"jszip": "^3.10.1", "json5": "^2.2.3",
"lucide-react": "^1.23.0", "jszip": "^3.10.1",
"lzma": "^2.3.2", "lucide-react": "^1.23.0",
"music-metadata": "^11.13.0", "lzma": "^2.3.2",
"mysql2": "^3.22.6", "music-metadata": "^11.13.0",
"next": "^16.2.10", "mysql2": "^3.22.6",
"next-auth": "5.0.0-beta.31", "next": "^16.2.10",
"next-intl": "^4.13.1", "next-auth": "5.0.0-beta.31",
"nodemailer": "^9.0.3", "next-intl": "^4.13.1",
"otplib": "^12.0.1", "nodemailer": "^9.0.3",
"radix-ui": "^1.6.2", "otplib": "^12.0.1",
"react": "^19.2.0", "radix-ui": "^1.6.2",
"react-dom": "^19.2.0", "react": "^19.2.0",
"react-hook-form": "^7.81.0", "react-dom": "^19.2.0",
"resend": "^6.17.1", "react-hook-form": "^7.81.0",
"sanitize-html": "^2.17.5", "resend": "^6.17.1",
"sonner": "^2.0.7", "sanitize-html": "^2.17.5",
"tailwind-merge": "^3.6.0", "sonner": "^2.0.7",
"zod": "^3.24.0" "tailwind-merge": "^3.6.0",
}, "zod": "^3.24.0"
"devDependencies": { },
"@eslint/js": "^10.0.1", "devDependencies": {
"@next/eslint-plugin-next": "^16.2.10", "@biomejs/biome": "2.5.3",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@types/jpeg-js": "^0.3.7", "@types/jpeg-js": "^0.3.7",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"@vitalets/google-translate-api": "^9.2.1", "@vitalets/google-translate-api": "^9.2.1",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"eslint": "^10.6.0", "postcss": "^8.5.16",
"eslint-config-prettier": "^10.1.8", "prisma": "^7.8.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "tailwindcss": "^4.3.2",
"eslint-plugin-react-hooks": "^7.1.1", "tsx": "^4.22.5",
"eslint-plugin-security": "^4.0.1", "typescript": "^5.7.0",
"postcss": "^8.5.16", "vitest": "^2.1.0"
"prettier": "^3.9.5", },
"prisma": "^7.8.0", "pnpm": {
"tailwindcss": "^4.3.2", "onlyBuiltDependencies": [
"tsx": "^4.22.5", "esbuild",
"typescript": "^5.7.0", "prisma",
"typescript-eslint": "^8.63.0", "@prisma/client",
"vitest": "^2.1.0" "@prisma/engines",
}, "sharp",
"pnpm": { "@parcel/watcher",
"onlyBuiltDependencies": [ "@swc/core"
"esbuild", ],
"prisma", "overrides": {
"@prisma/client", "fast-uri": "^3.1.3",
"@prisma/engines" "@hono/node-server": "^1.19.13",
], "postcss": "^8.5.16"
"overrides": { }
"fast-uri": "^3.1.3", }
"@hono/node-server": "^1.19.13",
"postcss": "^8.5.16"
}
}
} }
+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
@@ -1,112 +1,125 @@
{ {
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "AtomCMS-Next API", "title": "AtomCMS-Next API",
"version": "1.0.0", "version": "1.0.0",
"description": "Public and administrative REST API for the AtomCMS-Next Habbo retro hotel CMS." "description": "Public and administrative REST API for the AtomCMS-Next Habbo retro hotel CMS."
}, },
"servers": [{ "url": "/api", "description": "Local API" }], "servers": [{ "url": "/api", "description": "Local API" }],
"security": [{ "bearerAuth": [], "sessionAuth": [] }], "security": [{ "bearerAuth": [], "sessionAuth": [] }],
"components": { "components": {
"securitySchemes": { "securitySchemes": {
"bearerAuth": { "bearerAuth": {
"type": "http", "type": "http",
"scheme": "bearer", "scheme": "bearer",
"description": "Laravel Sanctum-compatible Bearer token from /api/tokens" "description": "Laravel Sanctum-compatible Bearer token from /api/tokens"
}, },
"sessionAuth": { "sessionAuth": {
"type": "apiKey", "type": "apiKey",
"in": "cookie", "in": "cookie",
"name": "next-auth.session-token", "name": "next-auth.session-token",
"description": "NextAuth session cookie (auto-sent by browser)" "description": "NextAuth session cookie (auto-sent by browser)"
} }
} }
}, },
"paths": { "paths": {
"/health": { "/health": {
"get": { "get": {
"summary": "Health check", "summary": "Health check",
"responses": { "200": { "description": "OK" } } "responses": { "200": { "description": "OK" } }
} }
}, },
"/articles": { "/articles": {
"get": { "get": {
"summary": "List published articles", "summary": "List published articles",
"parameters": [ "parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }, {
{ "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } } "name": "limit",
], "in": "query",
"responses": { "schema": { "type": "integer", "default": 10 }
"200": { },
"description": "Article list", {
"content": { "application/json": { "schema": { "type": "object" } } } "name": "offset",
} "in": "query",
} "schema": { "type": "integer", "default": 0 }
} }
}, ],
"/online": { "responses": {
"get": { "200": {
"summary": "Currently online users count", "description": "Article list",
"responses": { "content": {
"200": { "application/json": { "schema": { "type": "object" } }
"description": "Online count", }
"content": { }
"application/json": { }
"schema": { "type": "object", "properties": { "count": { "type": "integer" } } } }
} },
} "/online": {
} "get": {
} "summary": "Currently online users count",
} "responses": {
}, "200": {
"/leaderboard": { "description": "Online count",
"get": { "content": {
"summary": "User leaderboard (credits, achievement score, etc.)", "application/json": {
"responses": { "200": { "description": "Leaderboard data" } } "schema": {
} "type": "object",
}, "properties": { "count": { "type": "integer" } }
"/client/sso": { }
"get": { }
"summary": "Generate SSO ticket for the game client (requires auth)", }
"security": [{ "sessionAuth": [] }], }
"responses": { }
"200": { }
"description": "SSO ticket + hotel name + client URL", },
"content": { "/leaderboard": {
"application/json": { "get": {
"schema": { "summary": "User leaderboard (credits, achievement score, etc.)",
"type": "object", "responses": { "200": { "description": "Leaderboard data" } }
"properties": { }
"ticket": { "type": "string" }, },
"hotelName": { "type": "string" }, "/client/sso": {
"clientUrl": { "type": "string" } "get": {
} "summary": "Generate SSO ticket for the game client (requires auth)",
} "security": [{ "sessionAuth": [] }],
} "responses": {
} "200": {
}, "description": "SSO ticket + hotel name + client URL",
"401": { "description": "Unauthorized" } "content": {
} "application/json": {
} "schema": {
}, "type": "object",
"/me": { "properties": {
"get": { "ticket": { "type": "string" },
"summary": "Current user profile (requires auth)", "hotelName": { "type": "string" },
"security": [{ "sessionAuth": [] }], "clientUrl": { "type": "string" }
"responses": { "200": { "description": "User profile" } } }
} }
}, }
"/settings": { }
"get": { },
"summary": "Public site settings (non-sensitive keys only)", "401": { "description": "Unauthorized" }
"responses": { "200": { "description": "Settings object" } } }
} }
}, },
"/shop/packages": { "/me": {
"get": { "get": {
"summary": "Available shop packages", "summary": "Current user profile (requires auth)",
"responses": { "200": { "description": "Package list" } } "security": [{ "sessionAuth": [] }],
} "responses": { "200": { "description": "User profile" } }
} }
} },
"/settings": {
"get": {
"summary": "Public site settings (non-sensitive keys only)",
"responses": { "200": { "description": "Settings object" } }
}
},
"/shop/packages": {
"get": {
"summary": "Available shop packages",
"responses": { "200": { "description": "Package list" } }
}
}
}
} }
+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
@@ -4,54 +4,61 @@
const CACHE = "atom-v1"; const CACHE = "atom-v1";
self.addEventListener("install", () => { self.addEventListener("install", () => {
self.skipWaiting(); self.skipWaiting();
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches caches
.keys() .keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) .then((keys) =>
.then(() => self.clients.claim()), Promise.all(
); keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)),
),
)
.then(() => self.clients.claim()),
);
}); });
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
const req = event.request; const req = event.request;
if (req.method !== "GET") return; if (req.method !== "GET") return;
const url = new URL(req.url); const url = new URL(req.url);
if (url.origin !== self.location.origin) return; if (url.origin !== self.location.origin) return;
// Cache-first for immutable static assets. // Cache-first for immutable static assets.
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/_next/static/")) { if (
event.respondWith( url.pathname.startsWith("/assets/") ||
caches.open(CACHE).then((cache) => url.pathname.startsWith("/_next/static/")
cache.match(req).then( ) {
(hit) => event.respondWith(
hit || caches.open(CACHE).then((cache) =>
fetch(req).then((res) => { cache.match(req).then(
if (res.ok) cache.put(req, res.clone()); (hit) =>
return res; hit ||
}), fetch(req).then((res) => {
), if (res.ok) cache.put(req, res.clone());
), return res;
); }),
return; ),
} ),
);
return;
}
// Network-first for page navigations; fall back to cache, then the shell. // Network-first for page navigations; fall back to cache, then the shell.
if (req.mode === "navigate") { if (req.mode === "navigate") {
event.respondWith( event.respondWith(
fetch(req) fetch(req)
.then((res) => { .then((res) => {
const copy = res.clone(); const copy = res.clone();
caches caches
.open(CACHE) .open(CACHE)
.then((c) => c.put(req, copy)) .then((c) => c.put(req, copy))
.catch(() => {}); .catch(() => {});
return res; return res;
}) })
.catch(() => caches.match(req).then((hit) => hit || caches.match("/"))), .catch(() => caches.match(req).then((hit) => hit || caches.match("/"))),
); );
} }
}); });
+97 -89
View File
@@ -1,133 +1,141 @@
import "dotenv/config"; import "dotenv/config";
import { readFileSync, readdirSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { splitSqlStatements } from "./sql-statements";
import { mysqlConnectionUrl } from "./db-url"; import { mysqlConnectionUrl } from "./db-url";
import { splitSqlStatements } from "./sql-statements";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations"); const MIGRATIONS_DIR = resolve(__dirname, "../prisma/migrations");
const TRACKING_TABLE = "cms_migrations"; const TRACKING_TABLE = "cms_migrations";
interface MigrationFile { interface MigrationFile {
id: string; id: string;
name: string; name: string;
sql: string; sql: string;
} }
function getDbConfig(): { url: string; database: string } { function getDbConfig(): { url: string; database: string } {
const url = process.env.DATABASE_URL; const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is required"); if (!url) throw new Error("DATABASE_URL is required");
const parsed = new URL(url); const parsed = new URL(url);
const dbName = decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "atomcms"; const dbName =
return { url: mysqlConnectionUrl(url), database: dbName }; decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "atomcms";
return { url: mysqlConnectionUrl(url), database: dbName };
} }
async function ensureConnection(): Promise<void> { async function ensureConnection(): Promise<void> {
const { url } = getDbConfig(); const { url } = getDbConfig();
const mysql = await import("mysql2/promise"); const mysql = await import("mysql2/promise");
const conn = await mysql.createConnection(url); const conn = await mysql.createConnection(url);
try { try {
await conn.execute( await conn.execute(
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` ( `CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE, migration VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
); );
} finally { } finally {
await conn.end(); await conn.end();
} }
} }
async function getApplied(): Promise<Set<string>> { async function getApplied(): Promise<Set<string>> {
const { url } = getDbConfig(); const { url } = getDbConfig();
const mysql = await import("mysql2/promise"); const mysql = await import("mysql2/promise");
const conn = await mysql.createConnection(url); const conn = await mysql.createConnection(url);
try { try {
const [rows] = await conn.execute(`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`); const [rows] = await conn.execute(
return new Set((rows as { migration: string }[]).map((r) => r.migration)); `SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
} catch { );
return new Set(); return new Set((rows as { migration: string }[]).map((r) => r.migration));
} finally { } catch {
await conn.end(); return new Set();
} } finally {
await conn.end();
}
} }
function loadMigrations(): MigrationFile[] { function loadMigrations(): MigrationFile[] {
const entries = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }); const entries = readdirSync(MIGRATIONS_DIR, { withFileTypes: true });
const files = entries const files = entries
.filter((e) => e.isFile() && e.name.endsWith(".sql")) .filter((e) => e.isFile() && e.name.endsWith(".sql"))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
return files.map((f) => { return files.map((f) => {
const id = f.name.replace(/\.sql$/, ""); const id = f.name.replace(/\.sql$/, "");
const sql = readFileSync(resolve(MIGRATIONS_DIR, f.name), "utf-8"); const sql = readFileSync(resolve(MIGRATIONS_DIR, f.name), "utf-8");
return { id, name: f.name, sql }; return { id, name: f.name, sql };
}); });
} }
async function apply(migration: MigrationFile): Promise<void> { async function apply(migration: MigrationFile): Promise<void> {
const { url } = getDbConfig(); const { url } = getDbConfig();
const mysql = await import("mysql2/promise"); const mysql = await import("mysql2/promise");
const conn = await mysql.createConnection(url); const conn = await mysql.createConnection(url);
try { try {
const statements = splitSqlStatements(migration.sql); const statements = splitSqlStatements(migration.sql);
for (const stmt of statements) { for (const stmt of statements) {
await conn.execute(stmt); await conn.execute(stmt);
} }
await conn.execute(`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`, [migration.id]); await conn.execute(
console.log(`[migrate] Applied: ${migration.name}`); `INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`,
} finally { [migration.id],
await conn.end(); );
} console.log(`[migrate] Applied: ${migration.name}`);
} finally {
await conn.end();
}
} }
async function main() { async function main() {
const flag = process.argv[2]; const flag = process.argv[2];
if (flag === "--status") { if (flag === "--status") {
await ensureConnection(); await ensureConnection();
const applied = await getApplied(); const applied = await getApplied();
const all = loadMigrations(); const all = loadMigrations();
console.log("\nMigration status:\n"); console.log("\nMigration status:\n");
for (const m of all) { for (const m of all) {
const done = applied.has(m.id); const done = applied.has(m.id);
console.log(` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`); console.log(
} ` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`,
);
}
const pending = all.filter((m) => !applied.has(m.id)); const pending = all.filter((m) => !applied.has(m.id));
const total = all.length; const total = all.length;
const done = total - pending.length; const done = total - pending.length;
console.log(`\n${done}/${total} applied, ${pending.length} pending\n`); console.log(`\n${done}/${total} applied, ${pending.length} pending\n`);
return; return;
} }
await ensureConnection(); await ensureConnection();
const applied = await getApplied(); const applied = await getApplied();
const pending = loadMigrations().filter((m) => !applied.has(m.id)); const pending = loadMigrations().filter((m) => !applied.has(m.id));
if (pending.length === 0) { if (pending.length === 0) {
console.log("[migrate] All migrations already applied."); console.log("[migrate] All migrations already applied.");
return; return;
} }
console.log(`[migrate] Applying ${pending.length} migration(s)...\n`); console.log(`[migrate] Applying ${pending.length} migration(s)...\n`);
for (const m of pending) { for (const m of pending) {
try { try {
await apply(m); await apply(m);
} catch (err) { } catch (err) {
console.error(`[migrate] FAILED: ${m.name}`, err); console.error(`[migrate] FAILED: ${m.name}`, err);
process.exit(1); process.exit(1);
} }
} }
console.log("\n[migrate] Done."); console.log("\n[migrate] Done.");
} }
main().catch((err) => { main().catch((err) => {
console.error("[migrate] Fatal:", err); console.error("[migrate] Fatal:", err);
process.exit(1); process.exit(1);
}); });
+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
@@ -3,82 +3,96 @@ import { env } from "../src/env";
import { prisma } from "../src/lib/prisma"; import { prisma } from "../src/lib/prisma";
async function backupEmulatorJar(): Promise<void> { async function backupEmulatorJar(): Promise<void> {
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return; if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = await import("node:fs"); const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
const { resolve } = await import("node:path"); await import("node:fs");
const { resolve } = await import("node:path");
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-"); const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
const backupFile = resolve(env.EMULATOR_BACKUP_DIR, `emulator-${timestamp}.jar`); const backupFile = resolve(
env.EMULATOR_BACKUP_DIR,
`emulator-${timestamp}.jar`,
);
if (!existsSync(env.EMULATOR_BACKUP_DIR)) { if (!existsSync(env.EMULATOR_BACKUP_DIR)) {
mkdirSync(env.EMULATOR_BACKUP_DIR, { recursive: true }); mkdirSync(env.EMULATOR_BACKUP_DIR, { recursive: true });
} }
try { try {
copyFileSync(env.EMULATOR_JAR_PATH, backupFile); copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
console.log(`[jobs] Backed up emulator JAR to ${backupFile}`); console.log(`[jobs] Backed up emulator JAR to ${backupFile}`);
// Rotate: keep only the N newest // Rotate: keep only the N newest
const keep = env.EMULATOR_BACKUP_KEEP ?? 7; const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
const files = readdirSync(env.EMULATOR_BACKUP_DIR) const files = readdirSync(env.EMULATOR_BACKUP_DIR)
.filter((f) => f.startsWith("emulator-") && f.endsWith(".jar")) .filter((f) => f.startsWith("emulator-") && f.endsWith(".jar"))
.sort() .sort()
.reverse(); .reverse();
for (let i = keep; i < files.length; i++) { for (let i = keep; i < files.length; i++) {
unlinkSync(resolve(env.EMULATOR_BACKUP_DIR, files[i])); unlinkSync(resolve(env.EMULATOR_BACKUP_DIR, files[i]));
console.log(`[jobs] Rotated out old backup: ${files[i]}`); console.log(`[jobs] Rotated out old backup: ${files[i]}`);
} }
} catch (err) { } catch (err) {
console.error("[jobs] JAR backup failed:", err); console.error("[jobs] JAR backup failed:", err);
} }
} }
async function cleanupOldLogs(): Promise<void> { async function cleanupOldLogs(): Promise<void> {
try { try {
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await prisma.websiteLoginLogs.deleteMany({ where: { createdAt: { lt: cutoff } } }); await prisma.websiteLoginLogs.deleteMany({
console.log("[jobs] Cleaned up login logs older than 30 days"); where: { createdAt: { lt: cutoff } },
} catch (err) { });
console.error("[jobs] Log cleanup failed:", err); console.log("[jobs] Cleaned up login logs older than 30 days");
} } catch (err) {
console.error("[jobs] Log cleanup failed:", err);
}
} }
async function cleanupOldSessions(): Promise<void> { async function cleanupOldSessions(): Promise<void> {
try { try {
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
await prisma.passwordReset.deleteMany({ where: { createdAt: { lt: cutoff } } }); await prisma.passwordReset.deleteMany({
console.log("[jobs] Cleaned up expired password reset tokens"); where: { createdAt: { lt: cutoff } },
} catch (err) { });
console.error("[jobs] Session cleanup failed:", err); console.log("[jobs] Cleaned up expired password reset tokens");
} } catch (err) {
console.error("[jobs] Session cleanup failed:", err);
}
} }
async function main() { async function main() {
console.log("[jobs] Worker started"); console.log("[jobs] Worker started");
// JAR backup — daily at 03:00 // JAR backup — daily at 03:00
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) { if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
new Cron("0 3 * * *", () => { new Cron("0 3 * * *", () => {
backupEmulatorJar().catch((e) => console.error("[jobs] Backup error:", e)); backupEmulatorJar().catch((e) =>
}); console.error("[jobs] Backup error:", e),
console.log("[jobs] Scheduled: emulator JAR backup (daily 03:00)"); );
} });
console.log("[jobs] Scheduled: emulator JAR backup (daily 03:00)");
}
// Log cleanup — daily at 04:00 // Log cleanup — daily at 04:00
new Cron("0 4 * * *", () => { new Cron("0 4 * * *", () => {
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) => Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
console.error("[jobs] Cleanup error:", e), console.error("[jobs] Cleanup error:", e),
); );
}); });
console.log("[jobs] Scheduled: old data cleanup (daily 04:00)"); console.log("[jobs] Scheduled: old data cleanup (daily 04:00)");
// Run once on startup // Run once on startup
await Promise.all([backupEmulatorJar(), cleanupOldLogs(), cleanupOldSessions()]); await Promise.all([
backupEmulatorJar(),
cleanupOldLogs(),
cleanupOldSessions(),
]);
} }
main().catch((err) => { main().catch((err) => {
console.error("[jobs] Fatal:", err); console.error("[jobs] Fatal:", err);
process.exit(1); process.exit(1);
}); });
+104 -94
View File
@@ -9,132 +9,142 @@
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts * npx tsx scripts/migrate-aes-cbc-to-gcm.ts
*/ */
import "dotenv/config"; import "dotenv/config";
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import {
createCipheriv,
createDecipheriv,
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { prisma } from "../src/lib/prisma"; import { prisma } from "../src/lib/prisma";
function getKey(appKey: string): Buffer { function getKey(appKey: string): Buffer {
const raw = appKey.startsWith("base64:") const raw = appKey.startsWith("base64:")
? Buffer.from(appKey.slice("base64:".length), "base64") ? Buffer.from(appKey.slice("base64:".length), "base64")
: Buffer.from(appKey, "utf8"); : Buffer.from(appKey, "utf8");
if (raw.length !== 32) { if (raw.length !== 32) {
throw new Error(`APP_KEY must decode to 32 bytes (got ${raw.length})`); throw new Error(`APP_KEY must decode to 32 bytes (got ${raw.length})`);
} }
return raw; return raw;
} }
/** OLD: AES-256-CBC decrypt with HMAC-SHA256 verification. */ /** OLD: AES-256-CBC decrypt with HMAC-SHA256 verification. */
function decryptCbc(payload: string, key: Buffer, serialize = true): string { function decryptCbc(payload: string, key: Buffer, serialize = true): string {
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as { const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
iv: string; iv: string;
value: string; value: string;
mac: string; mac: string;
}; };
const expected = createHmac("sha256", key) const expected = createHmac("sha256", key)
.update(json.iv + json.value) .update(json.iv + json.value)
.digest("hex"); .digest("hex");
const a = Buffer.from(expected, "hex"); const a = Buffer.from(expected, "hex");
const b = Buffer.from(json.mac, "hex"); const b = Buffer.from(json.mac, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) { if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error("The MAC is invalid (CBC payload)."); throw new Error("The MAC is invalid (CBC payload).");
} }
const iv = Buffer.from(json.iv, "base64"); const iv = Buffer.from(json.iv, "base64");
const decipher = createDecipheriv("aes-256-cbc", key, iv); const decipher = createDecipheriv("aes-256-cbc", key, iv);
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8"); const plain =
return serialize ? phpUnserializeString(plain) : plain; decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
return serialize ? phpUnserializeString(plain) : plain;
} }
/** NEW: AES-256-GCM encrypt (mirrors current LaravelEncrypter). */ /** NEW: AES-256-GCM encrypt (mirrors current LaravelEncrypter). */
function encryptGcm(plaintext: string, key: Buffer, serialize = true): string { function encryptGcm(plaintext: string, key: Buffer, serialize = true): string {
const iv = randomBytes(12); const iv = randomBytes(12);
const data = serialize ? phpSerializeString(plaintext) : plaintext; const data = serialize ? phpSerializeString(plaintext) : plaintext;
const cipher = createCipheriv("aes-256-gcm", key, iv); const cipher = createCipheriv("aes-256-gcm", key, iv);
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64"); const valueB64 =
const tag = cipher.getAuthTag(); cipher.update(data, "utf8", "base64") + cipher.final("base64");
const ivB64 = iv.toString("base64"); const tag = cipher.getAuthTag();
const tagB64 = tag.toString("base64"); const ivB64 = iv.toString("base64");
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 }); const tagB64 = tag.toString("base64");
return Buffer.from(payload, "utf8").toString("base64"); const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
return Buffer.from(payload, "utf8").toString("base64");
} }
/** Tries to decrypt a payload with the NEW GCM logic; if it works, skip. */ /** Tries to decrypt a payload with the NEW GCM logic; if it works, skip. */
function isAlreadyGcm(payload: string, key: Buffer): boolean { function isAlreadyGcm(payload: string, _key: Buffer): boolean {
try { try {
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
if (!json.tag && !json.mac) return false; // can't determine format if (!json.tag && !json.mac) return false; // can't determine format
if (json.tag) return true; // has authTag => GCM if (json.tag) return true; // has authTag => GCM
return false; // has mac => CBC return false; // has mac => CBC
} catch { } catch {
return false; return false;
} }
} }
async function main() { async function main() {
const appKey = process.env.APP_KEY; const appKey = process.env.APP_KEY;
if (!appKey) { if (!appKey) {
console.error("APP_KEY environment variable is required."); console.error("APP_KEY environment variable is required.");
process.exit(1); process.exit(1);
} }
const key = getKey(appKey); const key = getKey(appKey);
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { twoFactorSecret: { not: null } }, where: { twoFactorSecret: { not: null } },
select: { id: true, twoFactorSecret: true }, select: { id: true, twoFactorSecret: true },
}); });
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`); console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
let migrated = 0; let migrated = 0;
let skipped = 0; let skipped = 0;
let errors = 0; let errors = 0;
for (const user of users) { for (const user of users) {
if (!user.twoFactorSecret) continue; if (!user.twoFactorSecret) continue;
if (isAlreadyGcm(user.twoFactorSecret, key)) { if (isAlreadyGcm(user.twoFactorSecret, key)) {
console.log(` [SKIP] User ${user.id} — already GCM`); console.log(` [SKIP] User ${user.id} — already GCM`);
skipped++; skipped++;
continue; continue;
} }
try { try {
const plaintext = decryptCbc(user.twoFactorSecret, key); const plaintext = decryptCbc(user.twoFactorSecret, key);
const reEncrypted = encryptGcm(plaintext, key); const reEncrypted = encryptGcm(plaintext, key);
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { twoFactorSecret: reEncrypted }, data: { twoFactorSecret: reEncrypted },
}); });
console.log(` [OK] User ${user.id} — migrated`); console.log(` [OK] User ${user.id} — migrated`);
migrated++; migrated++;
} catch (err) { } catch (err) {
console.error(` [FAIL] User ${user.id} — ${err}`); console.error(` [FAIL] User ${user.id} — ${err}`);
errors++; errors++;
} }
} }
console.log(`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`); console.log(
if (errors > 0) process.exit(1); `\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`,
);
if (errors > 0) process.exit(1);
} }
main() main()
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}) })
.finally(() => prisma.$disconnect()); .finally(() => prisma.$disconnect());
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */ /* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
function phpSerializeString(value: string): string { function phpSerializeString(value: string): string {
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`; return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
} }
function phpUnserializeString(serialized: string): string { function phpUnserializeString(serialized: string): string {
const m = /^s:(\d+):"/.exec(serialized); const m = /^s:(\d+):"/.exec(serialized);
if (!m) throw new Error("Not a serialized PHP string"); if (!m) throw new Error("Not a serialized PHP string");
const byteLen = Number(m[1]); const byteLen = Number(m[1]);
const start = m[0].length; const start = m[0].length;
const bytes = Buffer.from(serialized, "utf8").subarray( const bytes = Buffer.from(serialized, "utf8").subarray(
Buffer.byteLength(serialized.slice(0, start), "utf8"), Buffer.byteLength(serialized.slice(0, start), "utf8"),
); );
return bytes.subarray(0, byteLen).toString("utf8"); return bytes.subarray(0, byteLen).toString("utf8");
} }
+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"]
} }
+68 -68
View File
@@ -3,88 +3,88 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
// CRUD for website advertisements (website_ads). Emulator does not own this // CRUD for website advertisements (website_ads). Emulator does not own this
// table; it only stores an image URL rendered in the site layout/widgets. // table; it only stores an image URL rendered in the site layout/widgets.
export async function createAd(formData: FormData): Promise<void> { export async function createAd(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const image = String(formData.get("image") ?? "") const image = String(formData.get("image") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!image) return; if (!image) return;
const now = new Date(); const now = new Date();
try { try {
const ad = await prisma.websiteAds.create({ const ad = await prisma.websiteAds.create({
data: { image, createdAt: now, updatedAt: now }, data: { image, createdAt: now, updatedAt: now },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ad_create", action: "ad_create",
description: `Created advertisement #${ad.id} (${image})`, description: `Created advertisement #${ad.id} (${image})`,
targetType: "website_ad", targetType: "website_ad",
targetId: Number(ad.id), targetId: Number(ad.id),
}); });
} catch { } catch {
// DB error — page re-renders unchanged. // DB error — page re-renders unchanged.
revalidatePath("/admin/ads"); revalidatePath("/admin/ads");
return; return;
} }
redirect("/admin/ads"); redirect("/admin/ads");
} }
export async function updateAd(formData: FormData): Promise<void> { export async function updateAd(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const raw = String(formData.get("id") ?? "").normalize("NFC"); const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!/^\d+$/.test(raw)) return; if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw); const id = BigInt(raw);
const image = String(formData.get("image") ?? "") const image = String(formData.get("image") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!image) return; if (!image) return;
try { try {
await prisma.websiteAds.update({ await prisma.websiteAds.update({
where: { id }, where: { id },
data: { image, updatedAt: new Date() }, data: { image, updatedAt: new Date() },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ad_update", action: "ad_update",
description: `Updated advertisement #${id} (${image})`, description: `Updated advertisement #${id} (${image})`,
targetType: "website_ad", targetType: "website_ad",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Not found or DB error — ignore. // Not found or DB error — ignore.
revalidatePath(`/admin/ads/${id}`); revalidatePath(`/admin/ads/${id}`);
return; return;
} }
redirect("/admin/ads"); redirect("/admin/ads");
} }
export async function deleteAd(formData: FormData): Promise<void> { export async function deleteAd(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
try { try {
await prisma.websiteAds.delete({ where: { id } }); await prisma.websiteAds.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ad_delete", action: "ad_delete",
description: `Deleted advertisement #${id}`, description: `Deleted advertisement #${id}`,
targetType: "website_ad", targetType: "website_ad",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
redirect("/admin/ads"); redirect("/admin/ads");
} }
+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");
} }
+89 -83
View File
@@ -2,101 +2,107 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { slugify } from "@/lib/format";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { slugify } from "@/lib/format";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data";
async function uniqueSlug(title: string): Promise<string> { async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title); const base = slugify(title);
let slug = base; let slug = base;
let n = 2; let n = 2;
while (await prisma.websiteArticles.findUnique({ where: { slug }, select: { id: true } })) { while (
slug = `${base}-${n++}`; await prisma.websiteArticles.findUnique({
} where: { slug },
return slug; select: { id: true },
})
) {
slug = `${base}-${n++}`;
}
return slug;
} }
export async function createArticle(formData: FormData): Promise<void> { export async function createArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const title = String(formData.get("title") ?? "") const title = String(formData.get("title") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const shortStory = String(formData.get("shortStory") ?? "") const shortStory = String(formData.get("shortStory") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const fullStory = String(formData.get("fullStory") ?? "") const fullStory = String(formData.get("fullStory") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const image = String(formData.get("image") ?? "") const image = String(formData.get("image") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!title) return; if (!title) return;
try { try {
const now = new Date(); const now = new Date();
await prisma.websiteArticles.create({ await prisma.websiteArticles.create({
data: { data: {
slug: await uniqueSlug(title), slug: await uniqueSlug(title),
title: title.slice(0, 255), title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255), shortStory: shortStory.slice(0, 255),
fullStory, fullStory,
image: image.slice(0, 255), image: image.slice(0, 255),
userId: staff.id, userId: staff.id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { } catch {
// Database error — re-render unchanged with error. // Database error — re-render unchanged with error.
redirect("/admin/articles/new?error=Database error while creating article. Please try again."); redirect(
} "/admin/articles/new?error=Database error while creating article. Please try again.",
redirect("/admin/articles"); );
}
redirect("/admin/articles");
} }
export async function updateArticle(formData: FormData): Promise<void> { export async function updateArticle(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
try { try {
await prisma.websiteArticles.update({ await prisma.websiteArticles.update({
where: { id }, where: { id },
data: { data: {
title: String(formData.get("title") ?? "") title: String(formData.get("title") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
shortStory: String(formData.get("shortStory") ?? "") shortStory: String(formData.get("shortStory") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
fullStory: String(formData.get("fullStory") ?? "") fullStory: String(formData.get("fullStory") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(), .trim(),
image: String(formData.get("image") ?? "") image: String(formData.get("image") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
} catch { } catch {
redirect("/admin/articles?error=Update failed"); redirect("/admin/articles?error=Update failed");
} }
revalidatePath(`/admin/articles/${id}`); revalidatePath(`/admin/articles/${id}`);
redirect("/admin/articles"); redirect("/admin/articles");
} }
export async function deleteArticle(formData: FormData): Promise<void> { export async function deleteArticle(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = BigInt(String(formData.get("id"))); const id = BigInt(String(formData.get("id")));
try { try {
await prisma.$transaction([ await prisma.$transaction([
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }), prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }), prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
prisma.websiteArticles.delete({ where: { id } }), prisma.websiteArticles.delete({ where: { id } }),
]); ]);
} catch { } catch {
redirect("/admin/articles?error=Delete failed"); redirect("/admin/articles?error=Delete failed");
} }
redirect("/admin/articles"); redirect("/admin/articles");
} }
+45 -45
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import path from "node:path";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import path from "node:path";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -16,58 +16,58 @@ const MAX_BYTES = 1024 * 1024; // 1MB
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]); const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
function back(param: string, value: string): never { function back(param: string, value: string): never {
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`); redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
} }
export async function uploadBadge(formData: FormData): Promise<void> { export async function uploadBadge(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const dir = process.env.BADGE_UPLOAD_DIR; const dir = process.env.BADGE_UPLOAD_DIR;
if (!dir) { if (!dir) {
back("error", "Badge upload directory not configured"); back("error", "Badge upload directory not configured");
} }
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!CODE_RE.test(code)) { if (!CODE_RE.test(code)) {
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)"); back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
} }
const file = formData.get("file"); const file = formData.get("file");
if (!(file instanceof File)) { if (!(file instanceof File)) {
back("error", "No file uploaded"); back("error", "No file uploaded");
} }
if (file.size === 0) { if (file.size === 0) {
back("error", "Uploaded file is empty"); back("error", "Uploaded file is empty");
} }
if (file.size > MAX_BYTES) { if (file.size > MAX_BYTES) {
back("error", "File too large (max 1MB)"); back("error", "File too large (max 1MB)");
} }
if (!ALLOWED_TYPES.has(file.type)) { if (!ALLOWED_TYPES.has(file.type)) {
back("error", "File must be a GIF or PNG image"); back("error", "File must be a GIF or PNG image");
} }
try { try {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
const baseDir = path.resolve(dir); const baseDir = path.resolve(dir);
const target = path.resolve(baseDir, `${code}.gif`); const target = path.resolve(baseDir, `${code}.gif`);
if (!target.startsWith(baseDir + path.sep)) { if (!target.startsWith(baseDir + path.sep)) {
back("error", "Invalid path"); back("error", "Invalid path");
} }
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(target, buffer); await writeFile(target, buffer);
} catch { } catch {
back("error", "Could not write the badge file to disk"); back("error", "Could not write the badge file to disk");
} }
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "badge_upload", action: "badge_upload",
description: `Uploaded badge image "${code}.gif"`, description: `Uploaded badge image "${code}.gif"`,
targetType: "badge", targetType: "badge",
}); });
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`); redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
} }
+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");
} }
+57 -51
View File
@@ -1,71 +1,77 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import type { $Enums } from "@/generated/prisma/client";
import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard"; import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import type { $Enums } from "@/generated/prisma/client";
type BanType = $Enums.bans_type; type BanType = $Enums.bans_type;
const BAN_TYPES: ReadonlySet<string> = new Set(["account", "ip", "machine", "super"]); const BAN_TYPES: ReadonlySet<string> = new Set([
"account",
"ip",
"machine",
"super",
]);
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600; const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> { export async function createBan(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const reason = const reason =
String(formData.get("reason") ?? "") String(formData.get("reason") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 200) || "Banned"; .slice(0, 200) || "Banned";
const hours = Number(formData.get("hours")); const hours = Number(formData.get("hours"));
const type = String(formData.get("type")); const type = String(formData.get("type"));
if (!(userId > 0) || !BAN_TYPES.has(type)) return; if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS; const banExpire =
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { username: true }, select: { username: true },
}); });
await prisma.ban.create({ await prisma.ban.create({
data: { data: {
userId, userId,
ip: "", ip: "",
machineId: "", machineId: "",
userStaffId: staff.id, userStaffId: staff.id,
timestamp: now, timestamp: now,
banExpire, banExpire,
banReason: reason, banReason: reason,
type: type as BanType, type: type as BanType,
cfhTopic: -1, cfhTopic: -1,
}, },
}); });
if (user) await rcon.disconnectUser(userId, user.username); if (user) await rcon.disconnectUser(userId, user.username);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "user_ban", action: "user_ban",
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`, description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
targetType: "user", targetType: "user",
targetId: userId, targetId: userId,
}); });
revalidatePath("/admin/bans"); revalidatePath("/admin/bans");
} }
export async function liftBan(formData: FormData): Promise<void> { export async function liftBan(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (id > 0) { if (id > 0) {
await prisma.ban.delete({ where: { id } }); await prisma.ban.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ban_lift", action: "ban_lift",
description: `Lifted ban #${id}`, description: `Lifted ban #${id}`,
}); });
} }
revalidatePath("/admin/bans"); revalidatePath("/admin/bans");
} }
+60 -60
View File
@@ -2,75 +2,75 @@
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 createEmailTemplate(formData: FormData): Promise<void> { export async function createEmailTemplate(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const subject = String(formData.get("subject") ?? "") const subject = String(formData.get("subject") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const body = String(formData.get("body") ?? "").normalize("NFC"); const body = String(formData.get("body") ?? "").normalize("NFC");
const variablesRaw = String(formData.get("variables") ?? "") const variablesRaw = String(formData.get("variables") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const isActive = formData.get("isActive") != null; const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return; if (!name || !subject || !body) return;
await prisma.emailTemplates.create({ await prisma.emailTemplates.create({
data: { data: {
name, name,
subject, subject,
body, body,
variables: variablesRaw || null, variables: variablesRaw || null,
isActive, isActive,
}, },
}); });
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
export async function updateEmailTemplate(formData: FormData): Promise<void> { export async function updateEmailTemplate(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;
let id: bigint; let id: bigint;
try { try {
id = BigInt(raw); id = BigInt(raw);
} catch { } catch {
return; return;
} }
const subject = String(formData.get("subject") ?? "") const subject = String(formData.get("subject") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const body = String(formData.get("body") ?? "").normalize("NFC"); const body = String(formData.get("body") ?? "").normalize("NFC");
const variablesRaw = String(formData.get("variables") ?? "") const variablesRaw = String(formData.get("variables") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const isActive = formData.get("isActive") != null; const isActive = formData.get("isActive") != null;
if (!subject || !body) return; if (!subject || !body) return;
await prisma.emailTemplates.update({ await prisma.emailTemplates.update({
where: { id }, where: { id },
data: { data: {
subject, subject,
body, body,
variables: variablesRaw || null, variables: variablesRaw || null,
isActive, isActive,
}, },
}); });
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
export async function deleteEmailTemplate(formData: FormData): Promise<void> { export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
await prisma.emailTemplates.delete({ where: { id } }); await prisma.emailTemplates.delete({ where: { id } });
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
+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
@@ -3,165 +3,167 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry // CRUD for help-center FAQ entries (website_help_center_categories). Each entry
// is a titled content block with an optional image and call-to-action button. // is a titled content block with an optional image and call-to-action button.
function parsePosition(value: FormDataEntryValue | null): number { function parsePosition(value: FormDataEntryValue | null): number {
const n = Number(value); const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1; return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
} }
export async function createHelpQuestion(formData: FormData): Promise<void> { export async function createHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const content = String(formData.get("content") ?? "") const content = String(formData.get("content") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!name || !content) return; if (!name || !content) return;
const imageUrl = String(formData.get("imageUrl") ?? "") const imageUrl = String(formData.get("imageUrl") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "") const buttonText = String(formData.get("buttonText") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "") const buttonUrl = String(formData.get("buttonUrl") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const buttonColor = const buttonColor =
String(formData.get("buttonColor") ?? "") String(formData.get("buttonColor") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 16) || "#eeb425"; .slice(0, 16) || "#eeb425";
const buttonBorderColor = const buttonBorderColor =
String(formData.get("buttonBorderColor") ?? "") String(formData.get("buttonBorderColor") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 16) || "#facc15"; .slice(0, 16) || "#facc15";
try { try {
const entry = await prisma.websiteHelpCenterCategories.create({ const entry = await prisma.websiteHelpCenterCategories.create({
data: { data: {
name, name,
content, content,
position: parsePosition(formData.get("position")), position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
buttonText: buttonText || null, buttonText: buttonText || null,
buttonUrl: buttonUrl || null, buttonUrl: buttonUrl || null,
buttonColor, buttonColor,
buttonBorderColor, buttonBorderColor,
smallBox: formData.get("smallBox") != null, smallBox: formData.get("smallBox") != null,
}, },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "help_create", action: "help_create",
description: `Created help-center entry #${entry.id} (${name})`, description: `Created help-center entry #${entry.id} (${name})`,
targetType: "help_center_category", targetType: "help_center_category",
targetId: Number(entry.id), targetId: Number(entry.id),
}); });
} catch { } catch {
// Unique name collision or DB error — re-render unchanged with error. // Unique name collision or DB error — re-render unchanged with error.
revalidatePath("/admin/help-questions"); revalidatePath("/admin/help-questions");
redirect("/admin/help-questions/new?error=Unique name collision or database error. Please try again."); redirect(
} "/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
revalidatePath("/admin/help-questions"); );
redirect("/admin/help-questions"); }
revalidatePath("/admin/help-questions");
redirect("/admin/help-questions");
} }
export async function updateHelpQuestion(formData: FormData): Promise<void> { export async function updateHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const content = String(formData.get("content") ?? "") const content = String(formData.get("content") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!name || !content) return; if (!name || !content) return;
const imageUrl = String(formData.get("imageUrl") ?? "") const imageUrl = String(formData.get("imageUrl") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "") const buttonText = String(formData.get("buttonText") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "") const buttonUrl = String(formData.get("buttonUrl") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const buttonColor = const buttonColor =
String(formData.get("buttonColor") ?? "") String(formData.get("buttonColor") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 16) || "#eeb425"; .slice(0, 16) || "#eeb425";
const buttonBorderColor = const buttonBorderColor =
String(formData.get("buttonBorderColor") ?? "") String(formData.get("buttonBorderColor") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 16) || "#facc15"; .slice(0, 16) || "#facc15";
try { try {
await prisma.websiteHelpCenterCategories.update({ await prisma.websiteHelpCenterCategories.update({
where: { id }, where: { id },
data: { data: {
name, name,
content, content,
position: parsePosition(formData.get("position")), position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
buttonText: buttonText || null, buttonText: buttonText || null,
buttonUrl: buttonUrl || null, buttonUrl: buttonUrl || null,
buttonColor, buttonColor,
buttonBorderColor, buttonBorderColor,
smallBox: formData.get("smallBox") != null, smallBox: formData.get("smallBox") != null,
}, },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "help_update", action: "help_update",
description: `Updated help-center entry #${id} (${name})`, description: `Updated help-center entry #${id} (${name})`,
targetType: "help_center_category", targetType: "help_center_category",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Not found, unique collision, or DB error — ignore. // Not found, unique collision, or DB error — ignore.
revalidatePath(`/admin/help-questions/${id}`); revalidatePath(`/admin/help-questions/${id}`);
return; return;
} }
redirect("/admin/help-questions"); redirect("/admin/help-questions");
} }
export async function deleteHelpQuestion(formData: FormData): Promise<void> { export async function deleteHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
try { try {
await prisma.websiteHelpCenterCategories.delete({ where: { id } }); await prisma.websiteHelpCenterCategories.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "help_delete", action: "help_delete",
description: `Deleted help-center entry #${id}`, description: `Deleted help-center entry #${id}`,
targetType: "help_center_category", targetType: "help_center_category",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
redirect("/admin/help-questions"); redirect("/admin/help-questions");
} }
+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");
} }
+52 -50
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
const MEDIA_DIR = "public/assets/images/media"; const MEDIA_DIR = "public/assets/images/media";
@@ -10,63 +10,65 @@ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
export async function uploadMedia(formData: FormData): Promise<void> { export async function uploadMedia(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file || file.size === 0) return; if (!file || file.size === 0) return;
if (file.size > MAX_SIZE) return; if (file.size > MAX_SIZE) return;
if (!ALLOWED.includes(file.type)) return; if (!ALLOWED.includes(file.type)) return;
const baseDir = path.resolve(process.cwd(), MEDIA_DIR); const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true }); await mkdir(baseDir, { recursive: true });
const ext = file.name.split(".").pop() ?? "png"; const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const filePath = path.resolve(baseDir, name); const filePath = path.resolve(baseDir, name);
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path"); if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes)); await writeFile(filePath, Buffer.from(bytes));
revalidatePath("/api/media"); revalidatePath("/api/media");
revalidatePath("/admin/media"); revalidatePath("/admin/media");
} }
export async function deleteMedia(name: string): Promise<void> { export async function deleteMedia(name: string): Promise<void> {
await requireStaff(); await requireStaff();
const { unlink } = await import("fs/promises"); const { unlink } = await import("node:fs/promises");
const baseDir = path.resolve(process.cwd(), MEDIA_DIR); const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
const filePath = path.resolve(baseDir, name); const filePath = path.resolve(baseDir, name);
if (!filePath.startsWith(baseDir + path.sep)) return; if (!filePath.startsWith(baseDir + path.sep)) return;
try { try {
await unlink(filePath); await unlink(filePath);
} catch { } catch {
// File may not exist // File may not exist
} }
revalidatePath("/api/media"); revalidatePath("/api/media");
revalidatePath("/admin/media"); revalidatePath("/admin/media");
} }
export async function uploadMediaAndReturn(formData: FormData): Promise<string> { export async function uploadMediaAndReturn(
await requireStaff(); formData: FormData,
const file = formData.get("file") as File | null; ): Promise<string> {
if (!file || file.size === 0) return ""; await requireStaff();
if (file.size > MAX_SIZE) return ""; const file = formData.get("file") as File | null;
if (!ALLOWED.includes(file.type)) return ""; if (!file || file.size === 0) return "";
if (file.size > MAX_SIZE) return "";
if (!ALLOWED.includes(file.type)) return "";
const baseDir = path.resolve(process.cwd(), MEDIA_DIR); const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true }); await mkdir(baseDir, { recursive: true });
const ext = file.name.split(".").pop() ?? "png"; const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const filePath = path.resolve(baseDir, name); const filePath = path.resolve(baseDir, name);
if (!filePath.startsWith(baseDir + path.sep)) return ""; if (!filePath.startsWith(baseDir + path.sep)) return "";
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes)); await writeFile(filePath, Buffer.from(bytes));
revalidatePath("/api/media"); revalidatePath("/api/media");
revalidatePath("/admin/media"); revalidatePath("/admin/media");
return `/api/media/${name}`; return `/api/media/${name}`;
} }
+107 -98
View File
@@ -3,10 +3,10 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
// website_permissions (model WebsitePermissions) is the CMS-owned permission -> // website_permissions (model WebsitePermissions) is the CMS-owned permission ->
// minimum-rank mapping. Editable columns are exactly: permission (unique name), // minimum-rank mapping. Editable columns are exactly: permission (unique name),
@@ -14,112 +14,121 @@ import { logServerError } from "@/lib/server-log";
// created_at/updated_at are managed here. // created_at/updated_at are managed here.
function parseMinRank(formData: FormData): number { function parseMinRank(formData: FormData): number {
const n = Number( const n = Number(
String(formData.get("minRank") ?? "") String(formData.get("minRank") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(), .trim(),
); );
return Number.isInteger(n) && n >= 0 ? n : 1; return Number.isInteger(n) && n >= 0 ? n : 1;
} }
export async function createPermission(formData: FormData): Promise<void> { export async function createPermission(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = 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 = parseMinRank(formData); const minRank = parseMinRank(formData);
const description = const description =
String(formData.get("description") ?? "") String(formData.get("description") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() || null; .trim() || null;
if (!permission) return; if (!permission) return;
const now = new Date(); const now = new Date();
try { try {
await prisma.websitePermissions.upsert({ await prisma.websitePermissions.upsert({
where: { permission }, where: { permission },
update: { minRank, description, updatedAt: now }, update: { minRank, description, updatedAt: now },
create: { permission, minRank, description, createdAt: now, updatedAt: now }, create: {
}); permission,
await logStaffActivity({ minRank,
staffId: staff.id, description,
action: "permission_create", createdAt: now,
description: `Saved permission "${permission}" (min rank ${minRank})`, updatedAt: now,
targetType: "permission", },
}); });
} catch (error) { await logStaffActivity({
logServerError("admin.permission_create_failed", error, { staffId: staff.id, permission }); staffId: staff.id,
redirect("/admin/permissions?error=save"); action: "permission_create",
// ignore (e.g. constraint failure) — page re-renders current state description: `Saved permission "${permission}" (min rank ${minRank})`,
} targetType: "permission",
revalidatePath("/admin/permissions"); });
redirect("/admin/permissions?saved=1"); } catch (error) {
logServerError("admin.permission_create_failed", error, {
staffId: staff.id,
permission,
});
redirect("/admin/permissions?error=save");
// ignore (e.g. constraint failure) — page re-renders current state
}
revalidatePath("/admin/permissions");
redirect("/admin/permissions?saved=1");
} }
export async function updatePermission(formData: FormData): Promise<void> { export async function updatePermission(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const raw = String(formData.get("id") ?? "").normalize("NFC"); const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return; if (!raw) return;
const id = BigInt(raw); const id = BigInt(raw);
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 = parseMinRank(formData); const minRank = parseMinRank(formData);
const description = const description =
String(formData.get("description") ?? "") String(formData.get("description") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() || null; .trim() || null;
if (!permission) return; if (!permission) return;
try { try {
await prisma.websitePermissions.update({ await prisma.websitePermissions.update({
where: { id }, where: { id },
data: { permission, minRank, description, updatedAt: new Date() }, data: { permission, minRank, description, updatedAt: new Date() },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "permission_update", action: "permission_update",
description: `Updated permission #${id} ("${permission}" min rank ${minRank})`, description: `Updated permission #${id} ("${permission}" min rank ${minRank})`,
targetType: "permission", targetType: "permission",
}); });
} catch (error) { } catch (error) {
logServerError("admin.permission_update_failed", error, { logServerError("admin.permission_update_failed", error, {
staffId: staff.id, staffId: staff.id,
permissionId: String(id), permissionId: String(id),
}); });
redirect("/admin/permissions?error=save"); redirect("/admin/permissions?error=save");
// ignore (e.g. duplicate) — page re-renders current state // ignore (e.g. duplicate) — page re-renders current state
} }
revalidatePath("/admin/permissions"); revalidatePath("/admin/permissions");
redirect("/admin/permissions?saved=1"); redirect("/admin/permissions?saved=1");
} }
export async function deletePermission(formData: FormData): Promise<void> { export async function deletePermission(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
try { try {
const deleted = await prisma.websitePermissions.delete({ const deleted = await prisma.websitePermissions.delete({
where: { id }, where: { id },
select: { permission: true }, select: { permission: true },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "permission_delete", action: "permission_delete",
description: `Deleted permission #${id} ("${deleted.permission}")`, description: `Deleted permission #${id} ("${deleted.permission}")`,
targetType: "permission", targetType: "permission",
}); });
} catch (error) { } catch (error) {
logServerError("admin.permission_delete_failed", error, { logServerError("admin.permission_delete_failed", error, {
staffId: staff.id, staffId: staff.id,
permissionId: String(id), permissionId: String(id),
}); });
redirect("/admin/permissions?error=delete"); redirect("/admin/permissions?error=delete");
// ignore (e.g. already removed) // ignore (e.g. already removed)
} }
revalidatePath("/admin/permissions"); revalidatePath("/admin/permissions");
redirect("/admin/permissions?saved=1"); redirect("/admin/permissions?saved=1");
} }
+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");
} }
+86 -85
View File
@@ -13,118 +13,119 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
// `permissions` JSON column is intentionally left untouched by this CMS slice. // `permissions` JSON column is intentionally left untouched by this CMS slice.
function str(raw: FormDataEntryValue | null): string { function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
} }
/** Parse a BigInt id from a form value, or null when blank/invalid. */ /** Parse a BigInt id from a form value, or null when blank/invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null { function parseId(raw: FormDataEntryValue | null): bigint | null {
const s = str(raw).trim(); const s = str(raw).trim();
if (!s) return null; if (!s) return null;
try { try {
return BigInt(s); return BigInt(s);
} catch { } catch {
return null; return null;
} }
} }
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */ /** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
function intOr(raw: FormDataEntryValue | null, fallback: number): number { function intOr(raw: FormDataEntryValue | null, fallback: number): number {
const n = Number(str(raw).trim()); const n = Number(str(raw).trim());
if (!Number.isFinite(n) || n < 0) return fallback; if (!Number.isFinite(n) || n < 0) return fallback;
return Math.floor(n); return Math.floor(n);
} }
export async function createApiKey(formData: FormData): Promise<void> { export async function createApiKey(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const name = str(formData.get("name")).trim().slice(0, 255); const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return; if (!name) return;
const rateLimit = intOr(formData.get("rateLimit"), 300); const rateLimit = intOr(formData.get("rateLimit"), 300);
const allowedIps = str(formData.get("allowedIps")).trim().slice(0, 255) || null; const allowedIps =
str(formData.get("allowedIps")).trim().slice(0, 255) || null;
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)). // Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
const key = randomBytes(24).toString("hex"); const key = randomBytes(24).toString("hex");
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.radioApiKeys.create({ const created = await prisma.radioApiKeys.create({
data: { data: {
name, name,
key, key,
allowedIps, allowedIps,
rateLimit, rateLimit,
isActive: true, isActive: true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_api_key_create", action: "radio_api_key_create",
description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`, description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`,
targetType: "radio_api_key", targetType: "radio_api_key",
targetId: Number(created.id), targetId: Number(created.id),
}); });
} catch { } catch {
// Unique-key collision (astronomically unlikely) or DB down — fail soft. // Unique-key collision (astronomically unlikely) or DB down — fail soft.
return; return;
} }
revalidatePath("/admin/radio/api-keys"); revalidatePath("/admin/radio/api-keys");
redirect("/admin/radio/api-keys?created=1"); redirect("/admin/radio/api-keys?created=1");
} }
export async function toggleApiKey(formData: FormData): Promise<void> { export async function toggleApiKey(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 {
const existing = await prisma.radioApiKeys.findUnique({ const existing = await prisma.radioApiKeys.findUnique({
where: { id }, where: { id },
select: { name: true, isActive: true }, select: { name: true, isActive: true },
}); });
if (!existing) return; if (!existing) return;
const next = !existing.isActive; const next = !existing.isActive;
await prisma.radioApiKeys.update({ await prisma.radioApiKeys.update({
where: { id }, where: { id },
data: { isActive: next, updatedAt: new Date() }, data: { isActive: next, updatedAt: new Date() },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_api_key_toggle", action: "radio_api_key_toggle",
description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`, description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`,
targetType: "radio_api_key", targetType: "radio_api_key",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
return; return;
} }
revalidatePath("/admin/radio/api-keys"); revalidatePath("/admin/radio/api-keys");
} }
export async function deleteApiKey(formData: FormData): Promise<void> { export async function deleteApiKey(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.radioApiKeys.delete({ where: { id } }); await prisma.radioApiKeys.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_api_key_delete", action: "radio_api_key_delete",
description: `Deleted radio API key #${id}`, description: `Deleted radio API key #${id}`,
targetType: "radio_api_key", targetType: "radio_api_key",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
return; return;
} }
revalidatePath("/admin/radio/api-keys"); revalidatePath("/admin/radio/api-keys");
} }
+90 -90
View File
@@ -13,125 +13,125 @@ 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;
} }
} }
function str(raw: FormDataEntryValue | null): string { function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
} }
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */ /** Checkbox/select truthiness: '1', 'true', 'on' → true. */
function bool(raw: FormDataEntryValue | null): boolean { function bool(raw: FormDataEntryValue | null): boolean {
const v = str(raw).trim().toLowerCase(); const v = str(raw).trim().toLowerCase();
return v === "1" || v === "true" || v === "on"; return v === "1" || v === "true" || v === "on";
} }
/** Parse a non-negative UnsignedInt, falling back to 0. */ /** Parse a non-negative UnsignedInt, falling back to 0. */
function reqUInt(raw: FormDataEntryValue | null): number { function reqUInt(raw: FormDataEntryValue | null): number {
const n = Number(str(raw).trim()); const n = Number(str(raw).trim());
if (!Number.isFinite(n) || n < 0) return 0; if (!Number.isFinite(n) || n < 0) return 0;
return Math.trunc(n); return Math.trunc(n);
} }
/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */ /** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */
function optUInt(raw: FormDataEntryValue | null): number | null { function optUInt(raw: FormDataEntryValue | null): number | null {
const s = str(raw).trim(); const s = str(raw).trim();
if (s === "") return null; if (s === "") return null;
const n = Number(s); const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null; if (!Number.isFinite(n) || n < 0) return null;
return Math.trunc(n); return Math.trunc(n);
} }
// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ──────────────────────── // ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ────────────────────────
export async function createTrack(formData: FormData): Promise<void> { export async function createTrack(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const title = str(formData.get("title")).trim().slice(0, 255); const title = str(formData.get("title")).trim().slice(0, 255);
if (!title) return; if (!title) return;
const artist = str(formData.get("artist")).trim().slice(0, 255); const artist = str(formData.get("artist")).trim().slice(0, 255);
const album = str(formData.get("album")).trim().slice(0, 255); const album = str(formData.get("album")).trim().slice(0, 255);
const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255); const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255);
const duration = optUInt(formData.get("duration")); const duration = optUInt(formData.get("duration"));
const sortOrder = reqUInt(formData.get("sortOrder")); const sortOrder = reqUInt(formData.get("sortOrder"));
const isActive = bool(formData.get("isActive")); const isActive = bool(formData.get("isActive"));
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.radioAutoDjPlaylist.create({ const created = await prisma.radioAutoDjPlaylist.create({
data: { data: {
title, title,
artist: artist || null, artist: artist || null,
album: album || null, album: album || null,
artworkUrl: artworkUrl || null, artworkUrl: artworkUrl || null,
duration, duration,
sortOrder, sortOrder,
isActive, isActive,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_autodj_create", action: "radio_autodj_create",
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`, description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
targetType: "radio_auto_dj_track", targetType: "radio_auto_dj_track",
targetId: Number(created.id), targetId: Number(created.id),
}); });
} catch { } catch {
// Fail soft — DB unavailable; re-render without throwing. // Fail soft — DB unavailable; re-render without throwing.
} }
revalidatePath("/admin/radio/autodj"); revalidatePath("/admin/radio/autodj");
} }
export async function toggleTrack(formData: FormData): Promise<void> { export async function toggleTrack(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;
// The form posts the desired next state so the toggle is idempotent. // The form posts the desired next state so the toggle is idempotent.
const isActive = bool(formData.get("isActive")); const isActive = bool(formData.get("isActive"));
try { try {
await prisma.radioAutoDjPlaylist.update({ await prisma.radioAutoDjPlaylist.update({
where: { id }, where: { id },
data: { isActive, updatedAt: new Date() }, data: { isActive, updatedAt: new Date() },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_autodj_toggle", action: "radio_autodj_toggle",
description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`, description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`,
targetType: "radio_auto_dj_track", targetType: "radio_auto_dj_track",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Row may be gone; ignore. // Row may be gone; ignore.
} }
revalidatePath("/admin/radio/autodj"); revalidatePath("/admin/radio/autodj");
} }
export async function deleteTrack(formData: FormData): Promise<void> { export async function deleteTrack(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.radioAutoDjPlaylist.delete({ where: { id } }); await prisma.radioAutoDjPlaylist.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_autodj_delete", action: "radio_autodj_delete",
description: `Deleted AutoDJ track #${id}`, description: `Deleted AutoDJ track #${id}`,
targetType: "radio_auto_dj_track", targetType: "radio_auto_dj_track",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Already deleted; ignore. // Already deleted; ignore.
} }
revalidatePath("/admin/radio/autodj"); revalidatePath("/admin/radio/autodj");
} }
+168 -164
View File
@@ -9,23 +9,23 @@ import { siteSettings } from "@/lib/services/site-settings";
/** 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;
} }
} }
function str(raw: FormDataEntryValue | null): string { function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
} }
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */ /** Checkbox/select truthiness: '1', 'true', 'on' → true. */
function bool(raw: FormDataEntryValue | null): boolean { function bool(raw: FormDataEntryValue | null): boolean {
const v = str(raw).trim().toLowerCase(); const v = str(raw).trim().toLowerCase();
return v === "1" || v === "true" || v === "on"; return v === "1" || v === "true" || v === "on";
} }
// ── Radio settings (website_settings radio_* keys) ───────────────────────── // ── Radio settings (website_settings radio_* keys) ─────────────────────────
@@ -36,23 +36,23 @@ function bool(raw: FormDataEntryValue | null): boolean {
* siteSettings cache so the public radio pages pick the change up immediately. * siteSettings cache so the public radio pages pick the change up immediately.
*/ */
export async function saveRadioSetting(formData: FormData): Promise<void> { export async function saveRadioSetting(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const key = str(formData.get("key")).trim().slice(0, 255); const key = str(formData.get("key")).trim().slice(0, 255);
const value = str(formData.get("value")); const value = str(formData.get("value"));
const comment = str(formData.get("comment")).trim().slice(0, 255); const comment = str(formData.get("comment")).trim().slice(0, 255);
if (!key) return; if (!key) return;
try { try {
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();
} catch { } catch {
// DB unavailable — fail soft so the action does not throw. // DB unavailable — fail soft so the action does not throw.
} }
revalidatePath("/admin/radio/settings"); revalidatePath("/admin/radio/settings");
} }
/** /**
@@ -61,173 +61,177 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
* only touch those (and never wipe unrelated settings). * only touch those (and never wipe unrelated settings).
*/ */
export async function saveRadioSettings(formData: FormData): Promise<void> { export async function saveRadioSettings(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const keysRaw = str(formData.get("__keys")); const keysRaw = str(formData.get("__keys"));
const keys = keysRaw const keys = keysRaw
.split(",") .split(",")
.map((k) => k.trim()) .map((k) => k.trim())
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_")); .filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
if (keys.length === 0) return; if (keys.length === 0) return;
try { try {
await prisma.$transaction( await prisma.$transaction(
keys.map((key) => { keys.map((key) => {
const value = str(formData.get(key)); const value = str(formData.get(key));
return prisma.websiteSetting.upsert({ return prisma.websiteSetting.upsert({
where: { key }, where: { key },
update: { value }, update: { value },
create: { key, value, comment: null }, create: { key, value, comment: null },
}); });
}), }),
); );
siteSettings.reload(); siteSettings.reload();
} catch { } catch {
// Fail soft. // Fail soft.
} }
revalidatePath("/admin/radio/settings"); revalidatePath("/admin/radio/settings");
} }
// ── Radio banners CRUD (radio_banners) ───────────────────────────────────── // ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
export async function createRadioBanner(formData: FormData): Promise<void> { export async function createRadioBanner(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255); const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
if (!imagePath) return; if (!imagePath) return;
const title = str(formData.get("title")).trim().slice(0, 255); const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim(); const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder"))); const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0; const sortOrder = Number.isFinite(sortOrderNum)
const isActive = bool(formData.get("isActive")); ? Math.trunc(sortOrderNum)
const now = new Date(); : 0;
const isActive = bool(formData.get("isActive"));
const now = new Date();
try { try {
await prisma.radioBanners.create({ await prisma.radioBanners.create({
data: { data: {
userId: BigInt(staff.id), userId: BigInt(staff.id),
imagePath, imagePath,
title: title || null, title: title || null,
description: description || null, description: description || null,
sortOrder, sortOrder,
isActive, isActive,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { } catch {
// Fail soft. // Fail soft.
} }
revalidatePath("/admin/radio/banners"); revalidatePath("/admin/radio/banners");
} }
export async function updateRadioBanner(formData: FormData): Promise<void> { export async function updateRadioBanner(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255); const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
const title = str(formData.get("title")).trim().slice(0, 255); const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim(); const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder"))); const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0; const sortOrder = Number.isFinite(sortOrderNum)
const isActive = bool(formData.get("isActive")); ? Math.trunc(sortOrderNum)
if (!imagePath) return; : 0;
const isActive = bool(formData.get("isActive"));
if (!imagePath) return;
try { try {
await prisma.radioBanners.update({ await prisma.radioBanners.update({
where: { id }, where: { id },
data: { data: {
imagePath, imagePath,
title: title || null, title: title || null,
description: description || null, description: description || null,
sortOrder, sortOrder,
isActive, isActive,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
} catch { } catch {
// Row may be gone; ignore. // Row may be gone; ignore.
} }
revalidatePath("/admin/radio/banners"); revalidatePath("/admin/radio/banners");
} }
export async function deleteRadioBanner(formData: FormData): Promise<void> { export async function deleteRadioBanner(formData: FormData): Promise<void> {
await requireStaff(); 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.radioBanners.delete({ where: { id } }); await prisma.radioBanners.delete({ where: { id } });
} catch { } catch {
// Already deleted; ignore. // Already deleted; ignore.
} }
revalidatePath("/admin/radio/banners"); revalidatePath("/admin/radio/banners");
} }
// ── Radio ranks CRUD (radio_ranks) ───────────────────────────────────────── // ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
export async function createRadioRank(formData: FormData): Promise<void> { export async function createRadioRank(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const name = str(formData.get("name")).trim().slice(0, 255); const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return; if (!name) return;
const description = str(formData.get("description")).trim().slice(0, 255); const description = str(formData.get("description")).trim().slice(0, 255);
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255); const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
const isActive = bool(formData.get("isActive")); const isActive = bool(formData.get("isActive"));
const now = new Date(); const now = new Date();
try { try {
await prisma.radioRanks.create({ await prisma.radioRanks.create({
data: { data: {
name, name,
description: description || null, description: description || null,
badgeCode: badgeCode || null, badgeCode: badgeCode || null,
isActive, isActive,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch { } catch {
// Fail soft. // Fail soft.
} }
revalidatePath("/admin/radio/ranks"); revalidatePath("/admin/radio/ranks");
} }
export async function updateRadioRank(formData: FormData): Promise<void> { export async function updateRadioRank(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = parseId(formData.get("id")); const id = parseId(formData.get("id"));
if (id === null) return; if (id === null) return;
const name = str(formData.get("name")).trim().slice(0, 255); const name = str(formData.get("name")).trim().slice(0, 255);
const description = str(formData.get("description")).trim().slice(0, 255); const description = str(formData.get("description")).trim().slice(0, 255);
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255); const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
const isActive = bool(formData.get("isActive")); const isActive = bool(formData.get("isActive"));
if (!name) return; if (!name) return;
try { try {
await prisma.radioRanks.update({ await prisma.radioRanks.update({
where: { id }, where: { id },
data: { data: {
name, name,
description: description || null, description: description || null,
badgeCode: badgeCode || null, badgeCode: badgeCode || null,
isActive, isActive,
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
} catch { } catch {
// Row may be gone; ignore. // Row may be gone; ignore.
} }
revalidatePath("/admin/radio/ranks"); revalidatePath("/admin/radio/ranks");
} }
export async function deleteRadioRank(formData: FormData): Promise<void> { export async function deleteRadioRank(formData: FormData): Promise<void> {
await requireStaff(); 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.radioRanks.delete({ where: { id } }); await prisma.radioRanks.delete({ where: { id } });
} catch { } catch {
// Already deleted; ignore. // Already deleted; ignore.
} }
revalidatePath("/admin/radio/ranks"); revalidatePath("/admin/radio/ranks");
} }
+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");
} }
+57 -46
View File
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Radio listener-points settings (website_settings radio_points_* keys). // Radio listener-points settings (website_settings radio_points_* keys).
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in // Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
@@ -14,68 +14,79 @@ import { siteSettings } from "@/lib/services/site-settings";
// pages pick the change up immediately. // pages pick the change up immediately.
const POINTS_KEYS = [ const POINTS_KEYS = [
"radio_points_enabled", "radio_points_enabled",
"radio_points_per_minute", "radio_points_per_minute",
"radio_points_currency", "radio_points_currency",
"radio_points_max_per_day", "radio_points_max_per_day",
"radio_points_min_listeners", "radio_points_min_listeners",
] as const; ] as const;
const ALLOWED_CURRENCIES = new Set(["credits", "duckets", "diamonds", "points"]); const ALLOWED_CURRENCIES = new Set([
"credits",
"duckets",
"diamonds",
"points",
]);
function str(raw: FormDataEntryValue | null): string { function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
} }
/** Checkbox/select truthiness → '1' / '0'. */ /** Checkbox/select truthiness → '1' / '0'. */
function boolStr(raw: FormDataEntryValue | null): "0" | "1" { function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
const v = str(raw).trim().toLowerCase(); const v = str(raw).trim().toLowerCase();
return v === "1" || v === "true" || v === "on" ? "1" : "0"; return v === "1" || v === "true" || v === "on" ? "1" : "0";
} }
/** Clamp a form value to a non-negative integer string (defaulting to 0). */ /** Clamp a form value to a non-negative integer string (defaulting to 0). */
function intStr(raw: FormDataEntryValue | null): string { function intStr(raw: FormDataEntryValue | null): string {
const n = Number(str(raw).trim()); const n = Number(str(raw).trim());
if (!Number.isFinite(n) || n < 0) return "0"; if (!Number.isFinite(n) || n < 0) return "0";
return String(Math.floor(n)); return String(Math.floor(n));
} }
export async function savePoints(formData: FormData): Promise<void> { export async function savePoints(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const currencyRaw = str(formData.get("radio_points_currency")).trim().toLowerCase(); const currencyRaw = str(formData.get("radio_points_currency"))
const currency = ALLOWED_CURRENCIES.has(currencyRaw) ? currencyRaw : "credits"; .trim()
.toLowerCase();
const currency = ALLOWED_CURRENCIES.has(currencyRaw)
? currencyRaw
: "credits";
const values: Record<(typeof POINTS_KEYS)[number], string> = { const values: Record<(typeof POINTS_KEYS)[number], string> = {
radio_points_enabled: boolStr(formData.get("radio_points_enabled")), radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")), radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
radio_points_currency: currency, radio_points_currency: currency,
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")), radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
radio_points_min_listeners: intStr(formData.get("radio_points_min_listeners")), radio_points_min_listeners: intStr(
}; formData.get("radio_points_min_listeners"),
),
};
try { try {
await prisma.$transaction( await prisma.$transaction(
POINTS_KEYS.map((key) => POINTS_KEYS.map((key) =>
prisma.websiteSetting.upsert({ prisma.websiteSetting.upsert({
where: { key }, where: { key },
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const // eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
update: { value: values[key] }, update: { value: values[key] },
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const // eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
create: { key, value: values[key], comment: "Radio points" }, create: { key, value: values[key], comment: "Radio points" },
}), }),
), ),
); );
siteSettings.reload(); siteSettings.reload();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "radio_points_update", action: "radio_points_update",
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`, description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
}); });
} catch { } catch {
// DB unavailable — fail soft so the action does not throw. // DB unavailable — fail soft so the action does not throw.
} }
revalidatePath("/admin/radio/points"); revalidatePath("/admin/radio/points");
redirect("/admin/radio/points?saved=1"); redirect("/admin/radio/points?saved=1");
} }
+87 -83
View File
@@ -2,106 +2,110 @@
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 createCategory(formData: FormData): Promise<void> { export async function createCategory(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const badge = String(formData.get("badge") ?? "") const badge = String(formData.get("badge") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const priorityRaw = Number(formData.get("priority")); const priorityRaw = Number(formData.get("priority"));
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1; const priority =
if (!name || !badge) return; Number.isFinite(priorityRaw) && priorityRaw > 0
? Math.floor(priorityRaw)
: 1;
if (!name || !badge) return;
try { try {
await prisma.websiteRareValueCategories.create({ await prisma.websiteRareValueCategories.create({
data: { name, badge, priority }, data: { name, badge, priority },
}); });
} catch { } catch {
// Unique name collision or DB error — ignore, page will re-render unchanged. // Unique name collision or DB error — ignore, page will re-render unchanged.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
} }
export async function deleteCategory(formData: FormData): Promise<void> { export async function deleteCategory(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 {
// Remove the category's values first to avoid orphaned rows. // Remove the category's values first to avoid orphaned rows.
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } }); await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
await prisma.websiteRareValueCategories.delete({ where: { id } }); await prisma.websiteRareValueCategories.delete({ where: { id } });
} catch { } catch {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
} }
export async function createValue(formData: FormData): Promise<void> { export async function createValue(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const categoryId = formPositiveBigInt(formData, "categoryId"); const categoryId = formPositiveBigInt(formData, "categoryId");
if (!categoryId) return; if (!categoryId) return;
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "") const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!name || !furnitureIcon) return; if (!name || !furnitureIcon) return;
const itemIdRaw = Number(formData.get("itemId")); const itemIdRaw = Number(formData.get("itemId"));
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null; const itemId =
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
const creditValueRaw = String(formData.get("creditValue") ?? "") const creditValueRaw = String(formData.get("creditValue") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "") const currencyValueRaw = String(formData.get("currencyValue") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const currencyType = const currencyType =
String(formData.get("currencyType") ?? "diamonds") String(formData.get("currencyType") ?? "diamonds")
.trim() .trim()
.slice(0, 255) || "diamonds"; .slice(0, 255) || "diamonds";
try { try {
await prisma.websiteRareValues.create({ await prisma.websiteRareValues.create({
data: { data: {
categoryId, categoryId,
itemId, itemId,
name, name,
creditValue: creditValueRaw || null, creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null, currencyValue: currencyValueRaw || null,
currencyType, currencyType,
furnitureIcon, furnitureIcon,
}, },
}); });
} catch { } catch {
// DB error — ignore. // DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
} }
export async function deleteValue(formData: FormData): Promise<void> { export async function deleteValue(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.websiteRareValues.delete({ where: { id } }); await prisma.websiteRareValues.delete({ where: { id } });
} catch { } catch {
// Not found or DB error — ignore. // Not found or DB error — ignore.
} }
revalidatePath("/admin/rare-values"); revalidatePath("/admin/rare-values");
} }
+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
@@ -3,10 +3,10 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Website store packages (website_shop_articles). This CMS-owned table backs // Website store packages (website_shop_articles). This CMS-owned table backs
// the public store; rows here are the buyable packages, not orders. The closest // the public store; rows here are the buyable packages, not orders. The closest
@@ -14,162 +14,165 @@ import { logServerError } from "@/lib/server-log";
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */ /** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
function optUInt(formData: FormData, key: string): number | null { function optUInt(formData: FormData, key: string): number | null {
const raw = String(formData.get(key) ?? "") const raw = String(formData.get(key) ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (raw === "") return null; if (raw === "") return null;
const n = Number(raw); const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return null; if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n); return Math.floor(n);
} }
/** Parse a required non-negative UnsignedInt, falling back to 0. */ /** Parse a required non-negative UnsignedInt, falling back to 0. */
function reqUInt(formData: FormData, key: string): number { function reqUInt(formData: FormData, key: string): number {
const n = optUInt(formData, key); const n = optUInt(formData, key);
return n ?? 0; return n ?? 0;
} }
export async function createShopArticle(formData: FormData): Promise<void> { export async function createShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!name) return; if (!name) return;
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.websiteShopArticles.create({ const created = await prisma.websiteShopArticles.create({
data: { data: {
name, name,
info: String(formData.get("info") ?? "") info: String(formData.get("info") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
iconUrl: String(formData.get("icon") ?? "") iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
color: String(formData.get("color") ?? "") color: String(formData.get("color") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
costs: reqUInt(formData, "costs"), costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"), giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"), credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"), duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"), diamonds: optUInt(formData, "diamonds"),
badges: badges:
String(formData.get("badges") ?? "") String(formData.get("badges") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255) || null, .slice(0, 255) || null,
position: reqUInt(formData, "position"), position: reqUInt(formData, "position"),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "shop_create", action: "shop_create",
description: `Created shop package "${name}" (${created.costs} costs)`, description: `Created shop package "${name}" (${created.costs} costs)`,
targetType: "shop_article", targetType: "shop_article",
targetId: Number(created.id), targetId: Number(created.id),
}); });
} catch (error) { } catch (error) {
logServerError("admin.shop_create_failed", error, { staffId: staff.id, name }); logServerError("admin.shop_create_failed", error, {
// Unique constraint on `name` (or DB unavailable) — swallow and re-render. staffId: staff.id,
return; name,
} });
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
return;
}
redirect("/admin/shop"); redirect("/admin/shop");
} }
export async function updateShopArticle(formData: FormData): Promise<void> { export async function updateShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!name) return; if (!name) return;
try { try {
await prisma.websiteShopArticles.update({ await prisma.websiteShopArticles.update({
where: { id }, where: { id },
data: { data: {
name, name,
info: String(formData.get("info") ?? "") info: String(formData.get("info") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
iconUrl: String(formData.get("icon") ?? "") iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
color: String(formData.get("color") ?? "") color: String(formData.get("color") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255), .slice(0, 255),
costs: reqUInt(formData, "costs"), costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"), giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"), credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"), duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"), diamonds: optUInt(formData, "diamonds"),
badges: badges:
String(formData.get("badges") ?? "") String(formData.get("badges") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255) || null, .slice(0, 255) || null,
position: reqUInt(formData, "position"), position: reqUInt(formData, "position"),
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "shop_update", action: "shop_update",
description: `Updated shop package #${id} ("${name}")`, description: `Updated shop package #${id} ("${name}")`,
targetType: "shop_article", targetType: "shop_article",
targetId: Number(id), targetId: Number(id),
}); });
} catch (error) { } catch (error) {
logServerError("admin.shop_update_failed", error, { logServerError("admin.shop_update_failed", error, {
staffId: staff.id, staffId: staff.id,
articleId: String(id), articleId: String(id),
}); });
return; return;
} }
revalidatePath(`/admin/shop/${id}`); revalidatePath(`/admin/shop/${id}`);
redirect("/admin/shop"); redirect("/admin/shop");
} }
export async function deleteShopArticle(formData: FormData): Promise<void> { export async function deleteShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = formPositiveBigInt(formData, "id"); const id = formPositiveBigInt(formData, "id");
if (!id) return; if (!id) return;
try { try {
await prisma.websiteShopArticles.delete({ where: { id } }); await prisma.websiteShopArticles.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "shop_delete", action: "shop_delete",
description: `Deleted shop package #${id}`, description: `Deleted shop package #${id}`,
targetType: "shop_article", targetType: "shop_article",
targetId: Number(id), targetId: Number(id),
}); });
} catch (error) { } catch (error) {
logServerError("admin.shop_delete_failed", error, { logServerError("admin.shop_delete_failed", error, {
staffId: staff.id, staffId: staff.id,
articleId: String(id), articleId: String(id),
}); });
return; return;
} }
redirect("/admin/shop"); redirect("/admin/shop");
} }
+72 -72
View File
@@ -9,99 +9,99 @@ 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;
} }
} }
function str(raw: FormDataEntryValue | null): string { function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
} }
/** Normalise a hex-ish colour into the 10-char background_color column. */ /** Normalise a hex-ish colour into the 10-char background_color column. */
function normaliseColor(raw: string): string { function normaliseColor(raw: string): string {
const v = raw.trim().slice(0, 10); const v = raw.trim().slice(0, 10);
return v || "#888888"; return v || "#888888";
} }
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ────────── // ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
export async function createTag(formData: FormData): Promise<void> { export async function createTag(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const name = str(formData.get("name")).trim().slice(0, 255); const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return; if (!name) return;
const backgroundColor = normaliseColor(str(formData.get("backgroundColor"))); const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.tags.create({ const created = await prisma.tags.create({
data: { name, backgroundColor, createdAt: now, updatedAt: now }, data: { name, backgroundColor, createdAt: now, updatedAt: now },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "tag_create", action: "tag_create",
description: `Created tag "${name}" (#${created.id})`, description: `Created tag "${name}" (#${created.id})`,
targetType: "tag", targetType: "tag",
targetId: Number(created.id), targetId: Number(created.id),
}); });
} catch { } catch {
// Fail soft — DB unavailable or duplicate. // Fail soft — DB unavailable or duplicate.
} }
revalidatePath("/admin/tags"); revalidatePath("/admin/tags");
} }
export async function updateTag(formData: FormData): Promise<void> { export async function updateTag(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;
const name = str(formData.get("name")).trim().slice(0, 255); const name = str(formData.get("name")).trim().slice(0, 255);
const backgroundColor = normaliseColor(str(formData.get("backgroundColor"))); const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
if (!name) return; if (!name) return;
try { try {
await prisma.tags.update({ await prisma.tags.update({
where: { id }, where: { id },
data: { name, backgroundColor, updatedAt: new Date() }, data: { name, backgroundColor, updatedAt: new Date() },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "tag_update", action: "tag_update",
description: `Updated tag #${id} → "${name}"`, description: `Updated tag #${id} → "${name}"`,
targetType: "tag", targetType: "tag",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Row may be gone; ignore. // Row may be gone; ignore.
} }
revalidatePath("/admin/tags"); revalidatePath("/admin/tags");
} }
export async function deleteTag(formData: FormData): Promise<void> { export async function deleteTag(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 {
// Remove the tag and any taggable links pointing at it. // Remove the tag and any taggable links pointing at it.
await prisma.$transaction([ await prisma.$transaction([
prisma.taggables.deleteMany({ where: { tagId: id } }), prisma.taggables.deleteMany({ where: { tagId: id } }),
prisma.tags.delete({ where: { id } }), prisma.tags.delete({ where: { id } }),
]); ]);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "tag_delete", action: "tag_delete",
description: `Deleted tag #${id}`, description: `Deleted tag #${id}`,
targetType: "tag", targetType: "tag",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
// Already deleted; ignore. // Already deleted; ignore.
} }
revalidatePath("/admin/tags"); revalidatePath("/admin/tags");
} }
+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
@@ -6,14 +6,14 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import {
deleteCustomThemeStore,
getCustomTheme,
snapshotCurrentTheme,
upsertCustomTheme,
} from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings"; import { presetSettings, settingKey } from "@/lib/theme-settings";
import {
deleteCustomThemeStore,
getCustomTheme,
snapshotCurrentTheme,
upsertCustomTheme,
} from "@/lib/theme-custom-store";
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser). // Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
@@ -22,179 +22,180 @@ const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
const CUSTOM_CSS_MAX = 20000; const CUSTOM_CSS_MAX = 20000;
async function writeSetting(key: string, value: string): Promise<void> { async function writeSetting(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
where: { key }, where: { key },
update: { value }, update: { value },
create: { key, value, comment: "Theme (housekeeping)" }, create: { key, value, comment: "Theme (housekeeping)" },
}); });
} }
export async function saveTheme(formData: FormData): Promise<void> { export async function saveTheme(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
try { try {
for (const mode of ["light", "dark"] as const) { for (const mode of ["light", "dark"] as const) {
for (const key of THEME_COLOR_KEYS) { for (const key of THEME_COLOR_KEYS) {
const dbKey = settingKey(key, mode); const dbKey = settingKey(key, mode);
const raw = String(formData.get(dbKey) ?? "") const raw = String(formData.get(dbKey) ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw); if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw);
} }
} }
const ADMIN_KEYS = [ const ADMIN_KEYS = [
"admin_canvas", "admin_canvas",
"admin_surface", "admin_surface",
"admin_text", "admin_text",
"admin_text_muted", "admin_text_muted",
"admin_border", "admin_border",
"admin_sidebar_bg", "admin_sidebar_bg",
]; ];
for (const key of ADMIN_KEYS) { for (const key of ADMIN_KEYS) {
const raw = String(formData.get(key) ?? "") const raw = String(formData.get(key) ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw); if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
} }
const radius = String(formData.get("border_radius") ?? "") const radius = String(formData.get("border_radius") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius); if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
// Typography // Typography
const font = String(formData.get("font_family") ?? "") const font = String(formData.get("font_family") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (font in FONTS) await writeSetting("font_family", font); if (font in FONTS) await writeSetting("font_family", font);
for (const key of HEADING_KEYS) { for (const key of HEADING_KEYS) {
const v = String(formData.get(key) ?? "") const v = String(formData.get(key) ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v); if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
} }
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is). // Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
if (formData.has("custom_css")) { if (formData.has("custom_css")) {
const cssRaw = String(formData.get("custom_css") ?? "") const cssRaw = String(formData.get("custom_css") ?? "")
.normalize("NFC") .normalize("NFC")
.slice(0, CUSTOM_CSS_MAX); .slice(0, CUSTOM_CSS_MAX);
await writeSetting("custom_css", cssRaw); await writeSetting("custom_css", cssRaw);
} }
siteSettings.reload(); siteSettings.reload();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "theme_update", action: "theme_update",
description: "Updated theme settings", description: "Updated theme settings",
}); });
revalidatePath("/", "layout"); revalidatePath("/", "layout");
} catch { } catch {
// ignore — page re-renders current state // ignore — page re-renders current state
} }
redirect("/admin/theme?saved=1"); redirect("/admin/theme?saved=1");
} }
export async function applyPreset(formData: FormData): Promise<void> { export async function applyPreset(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const name = String(formData.get("preset") ?? "").normalize("NFC"); const name = String(formData.get("preset") ?? "").normalize("NFC");
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below // eslint-disable-next-line security/detect-object-injection -- guarded by null check below
const preset = PRESETS[name]; const preset = PRESETS[name];
if (!preset) redirect("/admin/theme"); if (!preset) redirect("/admin/theme");
try { try {
for (const [key, value] of presetSettings(preset)) await writeSetting(key, value); for (const [key, value] of presetSettings(preset))
await writeSetting("theme_preset", name); await writeSetting(key, value);
siteSettings.reload(); await writeSetting("theme_preset", name);
await logStaffActivity({ siteSettings.reload();
staffId: staff.id, await logStaffActivity({
action: "theme_preset", staffId: staff.id,
description: `Applied theme preset "${name}"`, action: "theme_preset",
}); description: `Applied theme preset "${name}"`,
revalidatePath("/", "layout"); });
} catch { revalidatePath("/", "layout");
// ignore } catch {
} // ignore
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`); }
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
} }
export async function saveCustomTheme(formData: FormData): Promise<void> { export async function saveCustomTheme(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!name) redirect("/admin/theme"); if (!name) redirect("/admin/theme");
const snapshot = await snapshotCurrentTheme(); const snapshot = await snapshotCurrentTheme();
try { try {
await upsertCustomTheme(name, snapshot); await upsertCustomTheme(name, snapshot);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "theme_preset", action: "theme_preset",
description: `Saved custom theme "${name}"`, description: `Saved custom theme "${name}"`,
}); });
revalidatePath("/admin/theme"); revalidatePath("/admin/theme");
} catch { } catch {
// ignore // ignore
} }
redirect("/admin/theme?savedTheme=1"); redirect("/admin/theme?savedTheme=1");
} }
export async function applyCustomTheme(formData: FormData): Promise<void> { export async function applyCustomTheme(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = String(formData.get("id") ?? "") const id = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!id) redirect("/admin/theme"); if (!id) redirect("/admin/theme");
const theme = await getCustomTheme(id); const theme = await getCustomTheme(id);
if (!theme) redirect("/admin/theme"); if (!theme) redirect("/admin/theme");
try { try {
for (const [key, value] of Object.entries(theme.settings)) { for (const [key, value] of Object.entries(theme.settings)) {
if (value) await writeSetting(key, value); if (value) await writeSetting(key, value);
} }
await writeSetting("theme_preset", theme.name); await writeSetting("theme_preset", theme.name);
siteSettings.reload(); siteSettings.reload();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "theme_preset", action: "theme_preset",
description: `Applied custom theme "${theme.name}"`, description: `Applied custom theme "${theme.name}"`,
}); });
revalidatePath("/", "layout"); revalidatePath("/", "layout");
} catch { } catch {
// ignore // ignore
} }
redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`); redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`);
} }
export async function renameCustomTheme(formData: FormData): Promise<void> { export async function renameCustomTheme(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = String(formData.get("id") ?? "") const id = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const name = String(formData.get("name") ?? "") const name = String(formData.get("name") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!id || !name) redirect("/admin/theme"); if (!id || !name) redirect("/admin/theme");
const snapshot = await snapshotCurrentTheme(); const snapshot = await snapshotCurrentTheme();
try { try {
await upsertCustomTheme(name, snapshot, id); await upsertCustomTheme(name, snapshot, id);
revalidatePath("/admin/theme"); revalidatePath("/admin/theme");
} catch { } catch {
// ignore // ignore
} }
redirect("/admin/theme?renamed=1"); redirect("/admin/theme?renamed=1");
} }
export async function deleteCustomTheme(formData: FormData): Promise<void> { export async function deleteCustomTheme(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const id = String(formData.get("id") ?? "") const id = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!id) redirect("/admin/theme"); if (!id) redirect("/admin/theme");
try { try {
await deleteCustomThemeStore(id); await deleteCustomThemeStore(id);
revalidatePath("/admin/theme"); revalidatePath("/admin/theme");
} catch { } catch {
// ignore // ignore
} }
redirect("/admin/theme?deletedTheme=1"); redirect("/admin/theme?deletedTheme=1");
} }
+69 -69
View File
@@ -13,13 +13,13 @@ const DUCKETS_TYPE = 0;
const DIAMONDS_TYPE = 5; const DIAMONDS_TYPE = 5;
function toInt(value: FormDataEntryValue | null, min = 0): number | null { function toInt(value: FormDataEntryValue | null, min = 0): number | null {
if (value == null) return null; if (value == null) return null;
const raw = String(value).trim(); const raw = String(value).trim();
if (raw === "") return null; if (raw === "") return null;
const n = Number(raw); const n = Number(raw);
if (!Number.isFinite(n)) return null; if (!Number.isFinite(n)) return null;
const i = Math.trunc(n); const i = Math.trunc(n);
return i < min ? min : i; return i < min ? min : i;
} }
/** /**
@@ -28,73 +28,73 @@ function toInt(value: FormDataEntryValue | null, min = 0): number | null {
* user from the session and logs the action. emulator-owned users.id is Int. * user from the session and logs the action. emulator-owned users.id is Int.
*/ */
export async function updateUser(formData: FormData): Promise<void> { export async function updateUser(formData: FormData): Promise<void> {
// Never trust the client: re-check staff inside the action. // Never trust the client: re-check staff inside the action.
const staff = await requireStaff(); const staff = await requireStaff();
const userId = Number(formData.get("id")); const userId = Number(formData.get("id"));
if (!Number.isInteger(userId) || userId <= 0) return; if (!Number.isInteger(userId) || userId <= 0) return;
const existing = await prisma.user.findUnique({ const existing = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { id: true }, select: { id: true },
}); });
if (!existing) return; if (!existing) return;
// users row — only existing, safe columns. // users row — only existing, safe columns.
const mailRaw = String(formData.get("mail") ?? "") const mailRaw = String(formData.get("mail") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const motto = String(formData.get("motto") ?? "") const motto = String(formData.get("motto") ?? "")
.normalize("NFC") .normalize("NFC")
.slice(0, 127); .slice(0, 127);
const look = String(formData.get("look") ?? "") const look = String(formData.get("look") ?? "")
.normalize("NFC") .normalize("NFC")
.slice(0, 256); .slice(0, 256);
const rank = toInt(formData.get("rank"), 1); const rank = toInt(formData.get("rank"), 1);
const credits = toInt(formData.get("credits"), 0); const credits = toInt(formData.get("credits"), 0);
const pixels = toInt(formData.get("pixels"), 0); const pixels = toInt(formData.get("pixels"), 0);
const points = toInt(formData.get("points"), 0); const points = toInt(formData.get("points"), 0);
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
mail: mailRaw === "" ? null : mailRaw.slice(0, 500), mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
motto, motto,
look, look,
...(rank != null ? { rank } : {}), ...(rank != null ? { rank } : {}),
...(credits != null ? { credits } : {}), ...(credits != null ? { credits } : {}),
...(pixels != null ? { pixels } : {}), ...(pixels != null ? { pixels } : {}),
...(points != null ? { points } : {}), ...(points != null ? { points } : {}),
}, },
}); });
// users_currency — set exact balances for duckets / diamonds. // users_currency — set exact balances for duckets / diamonds.
const duckets = toInt(formData.get("duckets"), 0); const duckets = toInt(formData.get("duckets"), 0);
const diamonds = toInt(formData.get("diamonds"), 0); const diamonds = toInt(formData.get("diamonds"), 0);
if (duckets != null) { if (duckets != null) {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: DUCKETS_TYPE } }, where: { userId_type: { userId, type: DUCKETS_TYPE } },
update: { amount: duckets }, update: { amount: duckets },
create: { userId, type: DUCKETS_TYPE, amount: duckets }, create: { userId, type: DUCKETS_TYPE, amount: duckets },
}); });
} }
if (diamonds != null) { if (diamonds != null) {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: DIAMONDS_TYPE } }, where: { userId_type: { userId, type: DIAMONDS_TYPE } },
update: { amount: diamonds }, update: { amount: diamonds },
create: { userId, type: DIAMONDS_TYPE, amount: diamonds }, create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
}); });
} }
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "user_edit", action: "user_edit",
description: `Edited account fields of user #${userId}`, description: `Edited account fields of user #${userId}`,
targetType: "user", targetType: "user",
targetId: userId, targetId: userId,
}); });
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
revalidatePath(`/admin/users/${userId}/edit`); revalidatePath(`/admin/users/${userId}/edit`);
redirect(`/admin/users/${userId}`); redirect(`/admin/users/${userId}`);
} }
+61 -51
View File
@@ -7,69 +7,79 @@ import { rcon } from "@/lib/services/rcon";
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
const CURRENCIES: ReadonlySet<string> = new Set(["credits", "duckets", "diamonds", "points"]); const CURRENCIES: ReadonlySet<string> = new Set([
"credits",
"duckets",
"diamonds",
"points",
]);
export async function giveCurrency(formData: FormData): Promise<void> { export async function giveCurrency(formData: FormData): Promise<void> {
const staff = await requireStaffRateLimited(); const staff = await requireStaffRateLimited();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const type = String(formData.get("type")); const type = String(formData.get("type"));
const amount = Number(formData.get("amount")); const amount = Number(formData.get("amount"));
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) { if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
await sendCurrency({ rcon, db: prisma }, userId, type as CurrencyName, amount); await sendCurrency(
await logStaffActivity({ { rcon, db: prisma },
staffId: staff.id, userId,
action: "give_currency", type as CurrencyName,
description: `Gave ${amount} ${type} to user #${userId}`, amount,
targetType: "user", );
targetId: userId, await logStaffActivity({
}); staffId: staff.id,
} action: "give_currency",
revalidatePath(`/admin/users/${userId}`); description: `Gave ${amount} ${type} to user #${userId}`,
targetType: "user",
targetId: userId,
});
}
revalidatePath(`/admin/users/${userId}`);
} }
export async function setMotto(formData: FormData): Promise<void> { export async function setMotto(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const motto = String(formData.get("motto") ?? "") const motto = String(formData.get("motto") ?? "")
.normalize("NFC") .normalize("NFC")
.slice(0, 127); .slice(0, 127);
if (userId > 0) { if (userId > 0) {
await prisma.user.update({ where: { id: userId }, data: { motto } }); await prisma.user.update({ where: { id: userId }, data: { motto } });
await rcon.setMotto(userId, motto); await rcon.setMotto(userId, motto);
} }
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
} }
export async function setRank(formData: FormData): Promise<void> { export async function setRank(formData: FormData): Promise<void> {
const staff = await requireStaffRateLimited(); const staff = await requireStaffRateLimited();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const rank = Number(formData.get("rank")); const rank = Number(formData.get("rank"));
if (userId > 0 && rank > 0) { if (userId > 0 && rank > 0) {
await prisma.user.update({ where: { id: userId }, data: { rank } }); await prisma.user.update({ where: { id: userId }, data: { rank } });
await rcon.setRank(userId, rank); await rcon.setRank(userId, rank);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "rank_change", action: "rank_change",
description: `Set rank of user #${userId} to ${rank}`, description: `Set rank of user #${userId} to ${rank}`,
targetType: "user", targetType: "user",
targetId: userId, targetId: userId,
}); });
} }
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
} }
export async function alertUser(formData: FormData): Promise<void> { export async function alertUser(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const message = String(formData.get("message") ?? "") const message = String(formData.get("message") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (userId > 0 && message) await rcon.alertUser(userId, message); if (userId > 0 && message) await rcon.alertUser(userId, message);
} }
export async function disconnectUser(formData: FormData): Promise<void> { export async function disconnectUser(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const username = String(formData.get("username") ?? "").normalize("NFC"); const username = String(formData.get("username") ?? "").normalize("NFC");
if (userId > 0) await rcon.disconnectUser(userId, username); if (userId > 0) await rcon.disconnectUser(userId, username);
} }
+50 -47
View File
@@ -2,67 +2,70 @@
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";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
export async function createVoucher(formData: FormData): Promise<void> { export async function createVoucher(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const amount = Number(formData.get("amount")); const amount = Number(formData.get("amount"));
const maxUsesRaw = Number(formData.get("maxUses")); const maxUsesRaw = Number(formData.get("maxUses"));
const maxUses = Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1; const maxUses =
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
if (!code || !(amount > 0)) return; if (!code || !(amount > 0)) return;
const expiresRaw = String(formData.get("expiresAt") ?? "") const expiresRaw = String(formData.get("expiresAt") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
let expiresAt: Date | null = null; let expiresAt: Date | null = null;
if (expiresRaw) { if (expiresRaw) {
const parsed = new Date(expiresRaw); const parsed = new Date(expiresRaw);
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed; if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
} }
const now = new Date(); const now = new Date();
try { try {
await prisma.websiteShopVouchers.create({ await prisma.websiteShopVouchers.create({
data: { data: {
code, code,
amount: Math.floor(amount), amount: Math.floor(amount),
maxUses, maxUses,
useCount: 0, useCount: 0,
expiresAt, expiresAt,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
}); });
} catch (error) { } catch (error) {
logServerError("admin.voucher_create_failed", error); logServerError("admin.voucher_create_failed", error);
// Unique constraint on `code` (or DB unavailable) — swallow and re-render. // Unique constraint on `code` (or DB unavailable) — swallow and re-render.
return; return;
} }
revalidatePath("/admin/vouchers"); revalidatePath("/admin/vouchers");
} }
export async function deleteVoucher(formData: FormData): Promise<void> { export async function deleteVoucher(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.websiteShopVouchers.delete({ where: { id } }); await prisma.websiteShopVouchers.delete({ where: { id } });
} catch (error) { } catch (error) {
logServerError("admin.voucher_delete_failed", error, { voucherId: String(id) }); logServerError("admin.voucher_delete_failed", error, {
return; voucherId: String(id),
} });
return;
}
revalidatePath("/admin/vouchers"); revalidatePath("/admin/vouchers");
} }
+62 -54
View File
@@ -15,67 +15,75 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]); const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
/** Upsert one website_settings key with a stable housekeeping comment. */ /** Upsert one website_settings key with a stable housekeeping comment. */
async function writeSetting(key: string, value: string, comment: string): Promise<void> { async function writeSetting(
await prisma.websiteSetting.upsert({ key: string,
where: { key }, value: string,
update: { value }, comment: string,
create: { key, value, comment }, ): Promise<void> {
}); await prisma.websiteSetting.upsert({
where: { key },
update: { value },
create: { key, value, comment },
});
} }
export async function saveVpn(formData: FormData): Promise<void> { export async function saveVpn(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
// Toggle: an unchecked checkbox submits nothing, so absence === disabled. // Toggle: an unchecked checkbox submits nothing, so absence === disabled.
const enabled = const enabled =
String(formData.get("vpn_block_enabled") ?? "") String(formData.get("vpn_block_enabled") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() !== ""; .trim() !== "";
const providerRaw = String(formData.get("vpn_provider") ?? "") const providerRaw = String(formData.get("vpn_provider") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.toLowerCase(); .toLowerCase();
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none"; const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
const apiKey = String(formData.get("vpn_api_key") ?? "") const apiKey = String(formData.get("vpn_api_key") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
const blockMessage = String(formData.get("vpn_block_message") ?? "") const blockMessage = String(formData.get("vpn_block_message") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
try { try {
await writeSetting( await writeSetting(
"vpn_block_enabled", "vpn_block_enabled",
enabled ? "1" : "0", enabled ? "1" : "0",
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)", "Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
); );
await writeSetting( await writeSetting(
"vpn_provider", "vpn_provider",
provider, provider,
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)", "VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
); );
await writeSetting("vpn_api_key", apiKey, "API key for the VPN/proxy detection provider"); await writeSetting(
await writeSetting( "vpn_api_key",
"vpn_block_message", apiKey,
blockMessage, "API key for the VPN/proxy detection provider",
"Message shown to users blocked for using a VPN/proxy", );
); await writeSetting(
"vpn_block_message",
blockMessage,
"Message shown to users blocked for using a VPN/proxy",
);
siteSettings.reload(); siteSettings.reload();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "vpn_update", action: "vpn_update",
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`, description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
}); });
revalidatePath("/admin/vpn"); revalidatePath("/admin/vpn");
} catch { } catch {
// DB unavailable — fail soft so the action does not throw; the page // DB unavailable — fail soft so the action does not throw; the page
// re-renders the current (stored) state. // re-renders the current (stored) state.
} }
redirect("/admin/vpn?saved=1"); redirect("/admin/vpn?saved=1");
} }
+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");
} }
+128 -126
View File
@@ -11,163 +11,165 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a non-negative Int form value, falling back to 0. */ /** Parse a non-negative Int form value, falling back to 0. */
function reqInt(formData: FormData, key: string): number { function reqInt(formData: FormData, key: string): number {
const raw = String(formData.get(key) ?? "") const raw = String(formData.get(key) ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (raw === "") return 0; if (raw === "") return 0;
const n = Number(raw); const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return 0; if (!Number.isFinite(n) || n < 0) return 0;
return Math.floor(n); return Math.floor(n);
} }
/** Parse the BigInt `id` form value, returning null when blank/invalid. */ /** Parse the BigInt `id` form value, returning null when blank/invalid. */
function parseId(formData: FormData): bigint | null { function parseId(formData: FormData): bigint | null {
const raw = String(formData.get("id") ?? "") const raw = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!raw) return null; if (!raw) return null;
try { try {
return BigInt(raw); return BigInt(raw);
} catch { } catch {
return null; return null;
} }
} }
function revalidate(): void { function revalidate(): void {
revalidatePath("/admin/writeable-boxes"); revalidatePath("/admin/writeable-boxes");
// Active boxes render on the public home page (root layout). // Active boxes render on the public home page (root layout).
revalidatePath("/", "layout"); revalidatePath("/", "layout");
} }
export async function createBox(formData: FormData): Promise<void> { export async function createBox(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const title = String(formData.get("title") ?? "") const title = String(formData.get("title") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!title) return; if (!title) return;
const now = new Date(); const now = new Date();
try { try {
const created = await prisma.websiteWriteableBoxes.create({ const created = await prisma.websiteWriteableBoxes.create({
data: { data: {
title, title,
icon: icon:
String(formData.get("icon") ?? "") String(formData.get("icon") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255) || null, .slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"), content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"), position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", isActive:
createdAt: now, String(formData.get("isActive") ?? "").normalize("NFC") === "1",
updatedAt: now, createdAt: now,
}, updatedAt: now,
}); },
await logStaffActivity({ });
staffId: staff.id, await logStaffActivity({
action: "writeable_box_create", staffId: staff.id,
description: `Created writeable box "${title}" (#${created.id})`, action: "writeable_box_create",
targetType: "writeable_box", description: `Created writeable box "${title}" (#${created.id})`,
targetId: Number(created.id), targetType: "writeable_box",
}); targetId: Number(created.id),
} catch { });
// DB unavailable — swallow and re-render. } catch {
return; // DB unavailable — swallow and re-render.
} return;
}
revalidate(); revalidate();
} }
export async function updateBox(formData: FormData): Promise<void> { export async function updateBox(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = parseId(formData); const id = parseId(formData);
if (id == null) return; if (id == null) return;
const title = String(formData.get("title") ?? "") const title = String(formData.get("title") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!title) return; if (!title) return;
try { try {
await prisma.websiteWriteableBoxes.update({ await prisma.websiteWriteableBoxes.update({
where: { id }, where: { id },
data: { data: {
title, title,
icon: icon:
String(formData.get("icon") ?? "") String(formData.get("icon") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255) || null, .slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"), content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"), position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", isActive:
updatedAt: new Date(), String(formData.get("isActive") ?? "").normalize("NFC") === "1",
}, updatedAt: new Date(),
}); },
await logStaffActivity({ });
staffId: staff.id, await logStaffActivity({
action: "writeable_box_update", staffId: staff.id,
description: `Updated writeable box #${id} ("${title}")`, action: "writeable_box_update",
targetType: "writeable_box", description: `Updated writeable box #${id} ("${title}")`,
targetId: Number(id), targetType: "writeable_box",
}); targetId: Number(id),
} catch { });
return; } catch {
} return;
}
revalidate(); revalidate();
} }
export async function deleteBox(formData: FormData): Promise<void> { export async function deleteBox(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = parseId(formData); const id = parseId(formData);
if (id == null) return; if (id == null) return;
try { try {
await prisma.websiteWriteableBoxes.delete({ where: { id } }); await prisma.websiteWriteableBoxes.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "writeable_box_delete", action: "writeable_box_delete",
description: `Deleted writeable box #${id}`, description: `Deleted writeable box #${id}`,
targetType: "writeable_box", targetType: "writeable_box",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
return; return;
} }
revalidate(); revalidate();
} }
export async function toggleBox(formData: FormData): Promise<void> { export async function toggleBox(formData: FormData): Promise<void> {
const staff = await requireStaff(); const staff = await requireStaff();
const id = parseId(formData); const id = parseId(formData);
if (id == null) return; if (id == null) return;
// `next` carries the desired state ("1" to activate, anything else to hide). // `next` carries the desired state ("1" to activate, anything else to hide).
const next = String(formData.get("next") ?? "").normalize("NFC") === "1"; const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
try { try {
await prisma.websiteWriteableBoxes.update({ await prisma.websiteWriteableBoxes.update({
where: { id }, where: { id },
data: { isActive: next, updatedAt: new Date() }, data: { isActive: next, updatedAt: new Date() },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "writeable_box_toggle", action: "writeable_box_toggle",
description: `${next ? "Activated" : "Hid"} writeable box #${id}`, description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
targetType: "writeable_box", targetType: "writeable_box",
targetId: Number(id), targetId: Number(id),
}); });
} catch { } catch {
return; return;
} }
revalidate(); revalidate();
} }
+53 -53
View File
@@ -20,38 +20,38 @@ const CONTENT_MAX = 5000;
* - content must be at least 10 characters. * - content must be at least 10 characters.
*/ */
export async function applyStaff(formData: FormData): Promise<void> { export async function applyStaff(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;
// rank_id comes from the open position's permission_id (an Int in the schema). // rank_id comes from the open position's permission_id (an Int in the schema).
const rankId = Number(formData.get("rankId")); const rankId = Number(formData.get("rankId"));
if (!Number.isInteger(rankId) || rankId <= 0) return; if (!Number.isInteger(rankId) || rankId <= 0) return;
const content = String(formData.get("content") ?? "") const content = String(formData.get("content") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, CONTENT_MAX); .slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return; if (content.length < CONTENT_MIN) return;
try { try {
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition). // Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
const existing = await prisma.websiteStaffApplications.findFirst({ const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId }, where: { userId, rankId },
select: { id: true }, select: { id: true },
}); });
if (existing) return; if (existing) return;
const now = new Date(); const now = new Date();
await prisma.websiteStaffApplications.create({ await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now }, data: { userId, rankId, content, createdAt: now, updatedAt: now },
}); });
} catch { } catch {
// DB unavailable — fail soft; nothing to persist. // DB unavailable — fail soft; nothing to persist.
return; return;
} }
revalidatePath("/apply/staff"); revalidatePath("/apply/staff");
} }
/** /**
@@ -64,35 +64,35 @@ export async function applyStaff(formData: FormData): Promise<void> {
* may only apply once per team. * may only apply once per team.
*/ */
export async function applyTeam(formData: FormData): Promise<void> { export async function applyTeam(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;
// website_teams.id is a BigInt; rank_id on the application is an Int. The team // website_teams.id is a BigInt; rank_id on the application is an Int. The team
// id is the application's rank flag. // id is the application's rank flag.
const rankId = Number(formData.get("teamId")); const rankId = Number(formData.get("teamId"));
if (!Number.isInteger(rankId) || rankId <= 0) return; if (!Number.isInteger(rankId) || rankId <= 0) return;
const content = String(formData.get("content") ?? "") const content = String(formData.get("content") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, CONTENT_MAX); .slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return; if (content.length < CONTENT_MIN) return;
try { try {
const existing = await prisma.websiteStaffApplications.findFirst({ const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId }, where: { userId, rankId },
select: { id: true }, select: { id: true },
}); });
if (existing) return; if (existing) return;
const now = new Date(); const now = new Date();
await prisma.websiteStaffApplications.create({ await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now }, data: { userId, rankId, content, createdAt: now, updatedAt: now },
}); });
} catch { } catch {
return; return;
} }
revalidatePath("/apply/team"); revalidatePath("/apply/team");
} }
+45 -45
View File
@@ -15,57 +15,57 @@ const COMMENT_MAX = 255;
* form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT). * form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT).
*/ */
export async function postComment(formData: FormData): Promise<void> { export async function postComment(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
if (!session?.user?.id) return; if (!session?.user?.id) return;
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return; if (!Number.isFinite(userId)) return;
const comment = String(formData.get("comment") ?? "") const comment = String(formData.get("comment") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, COMMENT_MAX); .slice(0, COMMENT_MAX);
if (!comment) return; if (!comment) 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(comment)).ok) return; if (!(await isAllowed(comment)).ok) return;
const articleIdRaw = String(formData.get("articleId") ?? "") const articleIdRaw = String(formData.get("articleId") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!/^\d+$/.test(articleIdRaw)) return; if (!/^\d+$/.test(articleIdRaw)) return;
let articleId: bigint; let articleId: bigint;
try { try {
articleId = BigInt(articleIdRaw); articleId = BigInt(articleIdRaw);
} catch { } catch {
return; return;
} }
let slug: string | null; let slug: string | null;
try { try {
// Confirm the article exists (and grab its slug for revalidation). // Confirm the article exists (and grab its slug for revalidation).
const article = await prisma.websiteArticles.findUnique({ const article = await prisma.websiteArticles.findUnique({
where: { id: articleId }, where: { id: articleId },
select: { slug: true }, select: { slug: true },
}); });
if (!article) return; if (!article) return;
slug = article.slug; slug = article.slug;
const now = new Date(); const now = new Date();
await prisma.websiteArticleComments.create({ await prisma.websiteArticleComments.create({
data: { data: {
articleId, articleId,
userId, userId,
comment, comment,
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 (slug) revalidatePath(`/news/${slug}`); if (slug) revalidatePath(`/news/${slug}`);
} }
+62 -62
View File
@@ -25,75 +25,75 @@ const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
* unique key, so we resolve the existing row with findFirst rather than upsert. * unique key, so we resolve the existing row with findFirst rather than upsert.
*/ */
export async function toggleReaction(formData: FormData): Promise<void> { export async function toggleReaction(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
if (!session?.user?.id) return; if (!session?.user?.id) return;
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return; if (!Number.isFinite(userId)) return;
const reaction = String(formData.get("reaction") ?? "") const reaction = String(formData.get("reaction") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.toLowerCase(); .toLowerCase();
if (!ALLOWED_REACTIONS.has(reaction)) return; if (!ALLOWED_REACTIONS.has(reaction)) return;
const articleIdRaw = String(formData.get("articleId") ?? "") const articleIdRaw = String(formData.get("articleId") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!/^\d+$/.test(articleIdRaw)) return; if (!/^\d+$/.test(articleIdRaw)) return;
let articleId: bigint; let articleId: bigint;
try { try {
articleId = BigInt(articleIdRaw); articleId = BigInt(articleIdRaw);
} catch { } catch {
return; return;
} }
let slug: string | null; let slug: string | null;
try { try {
// Confirm the article exists (and grab its slug for revalidation). // Confirm the article exists (and grab its slug for revalidation).
const article = await prisma.websiteArticles.findUnique({ const article = await prisma.websiteArticles.findUnique({
where: { id: articleId }, where: { id: articleId },
select: { slug: true }, select: { slug: true },
}); });
if (!article) return; if (!article) return;
slug = article.slug; slug = article.slug;
// The user's current row for THIS reaction on THIS article, if any. // The user's current row for THIS reaction on THIS article, if any.
const existing = await prisma.websiteArticleReactions.findFirst({ const existing = await prisma.websiteArticleReactions.findFirst({
where: { userId, articleId, reaction }, where: { userId, articleId, reaction },
select: { id: true, active: true }, select: { id: true, active: true },
}); });
if (existing?.active) { if (existing?.active) {
// Already reacting with this exact reaction -> un-vote (deactivate it). // Already reacting with this exact reaction -> un-vote (deactivate it).
await prisma.websiteArticleReactions.update({ await prisma.websiteArticleReactions.update({
where: { id: existing.id }, where: { id: existing.id },
data: { active: false }, data: { active: false },
}); });
} else { } else {
// Switching to (or first-time picking) this reaction: clear any other // Switching to (or first-time picking) this reaction: clear any other
// active reaction by this user on this article, then activate this one. // active reaction by this user on this article, then activate this one.
await prisma.websiteArticleReactions.updateMany({ await prisma.websiteArticleReactions.updateMany({
where: { userId, articleId, active: true }, where: { userId, articleId, active: true },
data: { active: false }, data: { active: false },
}); });
if (existing) { if (existing) {
await prisma.websiteArticleReactions.update({ await prisma.websiteArticleReactions.update({
where: { id: existing.id }, where: { id: existing.id },
data: { active: true }, data: { active: true },
}); });
} else { } else {
await prisma.websiteArticleReactions.create({ await prisma.websiteArticleReactions.create({
data: { userId, articleId, reaction, active: true }, data: { userId, articleId, reaction, active: true },
}); });
} }
} }
} catch { } catch {
// DB unavailable — fail soft; nothing to persist. // DB unavailable — fail soft; nothing to persist.
return; return;
} }
if (slug) revalidatePath(`/news/${slug}`); if (slug) revalidatePath(`/news/${slug}`);
} }
+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
@@ -8,63 +8,65 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
const bannerSchema = z.object({ const bannerSchema = z.object({
title: z.string().min(1).max(255), title: z.string().min(1).max(255),
subtitle: z.string().max(500).optional().default(""), subtitle: z.string().max(500).optional().default(""),
image: z.string().max(500), image: z.string().max(500),
link: z.string().max(500).optional().default(""), link: z.string().max(500).optional().default(""),
color: z.string().max(20).optional().default(""), color: z.string().max(20).optional().default(""),
isActive: z.coerce.number().int().min(0).max(1).default(1), isActive: z.coerce.number().int().min(0).max(1).default(1),
sortOrder: z.coerce.number().int().min(0).default(0), sortOrder: z.coerce.number().int().min(0).default(0),
startDate: z.string().max(50).nullable().optional(), startDate: z.string().max(50).nullable().optional(),
endDate: z.string().max(50).nullable().optional(), endDate: z.string().max(50).nullable().optional(),
}); });
export const createBanner = adminAction( export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema }, { permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => { async (ctx) => {
const banner = await prisma.websiteBanner.create({ data: ctx.data }); const banner = await prisma.websiteBanner.create({ data: ctx.data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "banner_create", action: "banner_create",
target: "WebsiteBanner", target: "WebsiteBanner",
targetId: banner.id, targetId: banner.id,
after: { title: banner.title }, after: { title: banner.title },
}); });
return actionOk({ id: banner.id }); return actionOk({ id: banner.id });
}, },
); );
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() }); const updateBannerInput = bannerSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateBanner = adminAction( export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteBanner.findUnique({ where: { id } }); const existing = await prisma.websiteBanner.findUnique({ where: { id } });
if (!existing) throw new ActionError("Banner not found"); if (!existing) throw new ActionError("Banner not found");
await prisma.websiteBanner.update({ where: { id }, data }); await prisma.websiteBanner.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "banner_update", action: "banner_update",
target: "WebsiteBanner", target: "WebsiteBanner",
targetId: id, targetId: id,
}); });
return actionOk({ id }); return actionOk({ id });
}, },
); );
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() }); const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction( export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => { async (ctx) => {
await prisma.websiteBanner.delete({ where: { id: ctx.data.id } }); await prisma.websiteBanner.delete({ where: { id: ctx.data.id } });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "banner_delete", action: "banner_delete",
target: "WebsiteBanner", target: "WebsiteBanner",
targetId: ctx.data.id, targetId: ctx.data.id,
}); });
return actionOk(); return actionOk();
}, },
); );
+148 -125
View File
@@ -1,159 +1,182 @@
"use server"; "use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function bulkUnban({ export async function bulkUnban({
userIds, userIds,
}: { }: {
userIds: number[]; userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> { }): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requireStaff(); const staff = await requireStaff();
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } }); const result = await prisma.ban.deleteMany({
await logStaffActivity({ where: { userId: { in: userIds } },
staffId: staff.id, });
action: "bulk_unban", await logStaffActivity({
description: `Unbanned ${result.count} user(s)`, staffId: staff.id,
targetType: "user", action: "bulk_unban",
}); description: `Unbanned ${result.count} user(s)`,
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } }; targetType: "user",
});
return {
ok: true as const,
data: { unbanned: result.count, total: userIds.length },
};
} }
export async function bulkBan({ export async function bulkBan({
userIds, userIds,
reason, reason,
duration, duration,
}: { }: {
userIds: number[]; userIds: number[];
reason: string; reason: string;
duration: number; duration: number;
}): Promise<ActionResult<{ banned: number }>> { }): Promise<ActionResult<{ banned: number }>> {
const staff = await requireStaff(); const staff = await requireStaff();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let banned = 0; let banned = 0;
for (const userId of userIds) { for (const userId of userIds) {
try { try {
await prisma.ban.create({ await prisma.ban.create({
data: { data: {
userId, userId,
ip: "", ip: "",
machineId: "", machineId: "",
userStaffId: staff.id, userStaffId: staff.id,
timestamp: now, timestamp: now,
banExpire: duration > 0 ? now + duration : 0, banExpire: duration > 0 ? now + duration : 0,
banReason: reason, banReason: reason,
type: "account", type: "account",
}, },
}); });
banned++; banned++;
} catch { } catch {
// skip duplicates // skip duplicates
} }
} }
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bulk_ban", action: "bulk_ban",
description: `Banned ${banned} user(s)`, description: `Banned ${banned} user(s)`,
targetType: "user", targetType: "user",
}); });
return { ok: true as const, data: { banned } }; return { ok: true as const, data: { banned } };
} }
export async function bulkGiveCurrency({ export async function bulkGiveCurrency({
userIds, userIds,
amount, amount,
type, type,
}: { }: {
userIds: number[]; userIds: number[];
amount: number; amount: number;
type: "credits" | "pixels" | "points"; type: "credits" | "pixels" | "points";
}): Promise< }): Promise<
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }> ActionResult<{
given: number;
total: number;
failedIds: Array<{ userId: number; reason: string }>;
}>
> { > {
const staff = await requireStaff(); const staff = await requireStaff();
let given = 0; let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = []; const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) { for (const userId of userIds) {
try { try {
if (type === "credits") { if (type === "credits") {
await prisma.user.update({ where: { id: userId }, data: { credits: { increment: amount } } }); await prisma.user.update({
await rcon.giveCredits(userId, amount); where: { id: userId },
} else if (type === "pixels") { data: { credits: { increment: amount } },
await prisma.usersCurrency.upsert({ });
where: { userId_type: { userId, type: 0 } }, await rcon.giveCredits(userId, amount);
update: { amount: { increment: amount } }, } else if (type === "pixels") {
create: { userId, type: 0, amount }, await prisma.usersCurrency.upsert({
}); where: { userId_type: { userId, type: 0 } },
await rcon.giveDuckets(userId, amount); update: { amount: { increment: amount } },
} else if (type === "points") { create: { userId, type: 0, amount },
await prisma.usersCurrency.upsert({ });
where: { userId_type: { userId, type: 101 } }, await rcon.giveDuckets(userId, amount);
update: { amount: { increment: amount } }, } else if (type === "points") {
create: { userId, type: 101, amount }, await prisma.usersCurrency.upsert({
}); where: { userId_type: { userId, type: 101 } },
await rcon.givePointsGotw(userId, amount); update: { amount: { increment: amount } },
} create: { userId, type: 101, amount },
given++; });
} catch { await rcon.givePointsGotw(userId, amount);
failedIds.push({ userId, reason: "Database error" }); }
} given++;
} } catch {
failedIds.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bulk_give_currency", action: "bulk_give_currency",
description: `Gave ${amount} ${type} to ${given} user(s)`, description: `Gave ${amount} ${type} to ${given} user(s)`,
targetType: "user", targetType: "user",
}); });
return { ok: true as const, data: { given, total: userIds.length, failedIds } }; return {
ok: true as const,
data: { given, total: userIds.length, failedIds },
};
} }
export async function bulkGiveBadge({ export async function bulkGiveBadge({
userIds, userIds,
badgeCode, badgeCode,
}: { }: {
userIds: number[]; userIds: number[];
badgeCode: string; badgeCode: string;
}): Promise< }): Promise<
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }> ActionResult<{
given: number;
total: number;
failedIds: Array<{ userId: number; reason: string }>;
}>
> { > {
const staff = await requireStaff(); const staff = await requireStaff();
let given = 0; let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = []; const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) { for (const userId of userIds) {
try { try {
const existing = await prisma.usersBadges.findFirst({ const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode }, where: { userId, badgeCode },
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({ data: { userId, slotId, badgeCode } }); await prisma.usersBadges.create({
await rcon.giveBadge(userId, badgeCode); data: { userId, slotId, badgeCode },
} });
given++; await rcon.giveBadge(userId, badgeCode);
} catch { }
failedIds.push({ userId, reason: "Database error" }); given++;
} } catch {
} failedIds.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bulk_give_badge", action: "bulk_give_badge",
description: `Gave badge "${badgeCode}" to ${given} user(s)`, description: `Gave badge "${badgeCode}" to ${given} user(s)`,
targetType: "user", targetType: "user",
}); });
return { ok: true as const, data: { given, total: userIds.length, failedIds } }; return {
ok: true as const,
data: { given, total: userIds.length, failedIds },
};
} }
+108 -95
View File
@@ -6,116 +6,129 @@ import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateBcPage({ id, ...fields }: { id: number } & Record<string, unknown>) { export async function updateBcPage({
const staff = await requireStaff(); id,
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any }); ...fields
await rcon.updateCatalog(); }: { id: number } & Record<string, unknown>) {
await logStaffActivity({ const staff = await requireStaff();
staffId: staff.id, await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
action: "bc_page_update", await rcon.updateCatalog();
description: `Updated BC catalog page #${id}`, await logStaffActivity({
targetType: "catalog_page_bc", staffId: staff.id,
targetId: id, action: "bc_page_update",
}); description: `Updated BC catalog page #${id}`,
revalidatePath("/admin/catalog/builder-club"); targetType: "catalog_page_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
} }
export async function deleteBcItem({ id }: { id: number }) { export async function deleteBcItem({ id }: { id: number }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogItemsBc.delete({ where: { id } }); await prisma.catalogItemsBc.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bc_item_delete", action: "bc_item_delete",
description: `Deleted BC catalog item #${id}`, description: `Deleted BC catalog item #${id}`,
targetType: "catalog_item_bc", targetType: "catalog_item_bc",
targetId: id, targetId: id,
}); });
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
} }
export async function updateBcItem({ export async function updateBcItem({
id, id,
...data ...data
}: { }: {
id: number; id: number;
itemIds?: string; itemIds?: string;
catalogName?: string; catalogName?: string;
orderNumber?: number; orderNumber?: number;
extradata?: string; extradata?: string;
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogItemsBc.update({ where: { id }, data: data as any }); await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bc_item_update", action: "bc_item_update",
description: `Updated BC catalog item #${id}`, description: `Updated BC catalog item #${id}`,
targetType: "catalog_item_bc", targetType: "catalog_item_bc",
targetId: id, targetId: id,
}); });
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
} }
export async function createBcItem({ export async function createBcItem({
pageId, pageId,
...data ...data
}: { }: {
pageId: number; pageId: number;
itemIds: string; itemIds: string;
catalogName: string; catalogName: string;
orderNumber: number; orderNumber: number;
extradata: string; extradata: string;
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
const created = await prisma.catalogItemsBc.create({ const created = await prisma.catalogItemsBc.create({
data: { pageId, ...data }, data: { pageId, ...data },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bc_item_create", action: "bc_item_create",
description: `Created BC catalog item #${created.id}`, description: `Created BC catalog item #${created.id}`,
targetType: "catalog_item_bc", targetType: "catalog_item_bc",
targetId: created.id, targetId: created.id,
}); });
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
} }
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) { export async function toggleBcPage({
await requireStaff(); id,
const page = await prisma.catalogPagesBc.findUnique({ field,
where: { id }, }: {
select: { enabled: true, visible: true }, id: number;
}); field: "enabled" | "visible";
if (!page) return; }) {
await prisma.catalogPagesBc.update({ await requireStaff();
where: { id }, const page = await prisma.catalogPagesBc.findUnique({
data: { [field]: page[field] === "1" ? "0" : "1" }, where: { id },
}); select: { enabled: true, visible: true },
revalidatePath("/admin/catalog/builder-club"); });
if (!page) return;
await prisma.catalogPagesBc.update({
where: { id },
data: { [field]: page[field] === "1" ? "0" : "1" },
});
revalidatePath("/admin/catalog/builder-club");
} }
export async function createBcPage(input: { caption: string; parentId: number; pageLayout: string }) { export async function createBcPage(input: {
const staff = await requireStaff(); caption: string;
const created = await prisma.catalogPagesBc.create({ parentId: number;
data: { pageLayout: string;
caption: input.caption, }) {
parentId: input.parentId, const staff = await requireStaff();
pageLayout: input.pageLayout, const created = await prisma.catalogPagesBc.create({
iconColor: 0, data: {
iconImage: 0, caption: input.caption,
orderNum: 0, parentId: input.parentId,
visible: "1", pageLayout: input.pageLayout,
enabled: "1", iconColor: 0,
pageHeadline: "", iconImage: 0,
pageTeaser: "", orderNum: 0,
}, visible: "1",
}); enabled: "1",
await logStaffActivity({ pageHeadline: "",
staffId: staff.id, pageTeaser: "",
action: "bc_page_create", },
description: `Created BC catalog page "${input.caption}"`, });
targetType: "catalog_page_bc", await logStaffActivity({
targetId: created.id, staffId: staff.id,
}); action: "bc_page_create",
revalidatePath("/admin/catalog/builder-club"); description: `Created BC catalog page "${input.caption}"`,
return { ok: true as const, data: { id: created.id } }; targetType: "catalog_page_bc",
targetId: created.id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: created.id } };
} }
+136 -124
View File
@@ -7,153 +7,165 @@ import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
export async function createCatalogItem(data: { export async function createCatalogItem(data: {
pageId: number; pageId: number;
itemIds: string; itemIds: string;
catalogName: string; catalogName: string;
costCredits: number; costCredits: number;
costPoints: number; costPoints: number;
pointsType: number; pointsType: number;
amount: number; amount: number;
orderNumber: number; orderNumber: number;
offerId: number; offerId: number;
limitedSells: number; limitedSells: number;
limitedStack: number; limitedStack: number;
extradata: string; extradata: string;
songId: number; songId: number;
haveOffer: "0" | "1"; haveOffer: "0" | "1";
clubOnly: "0" | "1"; clubOnly: "0" | "1";
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
const created = await prisma.catalogItems.create({ data }); const created = await prisma.catalogItems.create({ data });
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_item_create", action: "catalog_item_create",
description: `Created catalog item #${created.id}`, description: `Created catalog item #${created.id}`,
targetType: "catalog_item", targetType: "catalog_item",
targetId: created.id, targetId: created.id,
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: created.id } };
} }
export async function deleteCatalogItems({ ids }: { ids: number[] }) { export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } }); await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } });
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_items_delete", action: "catalog_items_delete",
description: `Deleted catalog items: ${ids.join(", ")}`, description: `Deleted catalog items: ${ids.join(", ")}`,
targetType: "catalog_item", targetType: "catalog_item",
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; targetPageId: number }) { export async function moveCatalogItems({
await requireStaff(); ids,
await prisma.catalogItems.updateMany({ targetPageId,
where: { id: { in: ids } }, }: {
data: { pageId: targetPageId }, ids: number[];
}); targetPageId: number;
await rcon.updateCatalog(); }) {
revalidatePath("/admin/catalog"); await requireStaff();
return { ok: true as const, data: {} }; await prisma.catalogItems.updateMany({
where: { id: { in: ids } },
data: { pageId: targetPageId },
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
} }
export async function reorderCatalogItems({ export async function reorderCatalogItems({
orders, orders,
}: { }: {
orders: Array<{ id: number; orderNumber: number }>; orders: Array<{ id: number; orderNumber: number }>;
}) { }) {
await requireStaff(); await requireStaff();
for (const { id, orderNumber } of orders) { for (const { id, orderNumber } of orders) {
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } }); await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
} }
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
export async function updateCatalogItem({ export async function updateCatalogItem({
id, id,
catalogFields, catalogFields,
baseItem, baseItem,
}: { }: {
id: number; id: number;
catalogFields: Record<string, unknown>; catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> }; baseItem?: { id: number; fields: Record<string, unknown> };
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any }); await prisma.catalogItems.update({
if (baseItem) { where: { id },
await prisma.itemsBase.update({ where: { id: baseItem.id }, data: baseItem.fields as any }); data: catalogFields as any,
} });
await rcon.updateCatalog(); if (baseItem) {
await logStaffActivity({ await prisma.itemsBase.update({
staffId: staff.id, where: { id: baseItem.id },
action: "catalog_item_update", data: baseItem.fields as any,
description: `Updated catalog item #${id}`, });
targetType: "catalog_item", }
targetId: id, await rcon.updateCatalog();
}); await logStaffActivity({
revalidatePath("/admin/catalog"); staffId: staff.id,
return { ok: true as const, data: {} }; action: "catalog_item_update",
description: `Updated catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
} }
export async function translateCatalogItems({ export async function translateCatalogItems({
items, items,
}: { }: {
items: Array<{ id: number; publicName: string; description: string }>; items: Array<{ id: number; publicName: string; description: string }>;
}) { }) {
await requireStaff(); await requireStaff();
let namesUpdated = 0; let namesUpdated = 0;
let descriptionsUpdated = 0; let descriptionsUpdated = 0;
let furniDataUpdated = 0; let furniDataUpdated = 0;
let furniDataInserted = 0; const _furniDataInserted = 0;
for (const item of items) { for (const item of items) {
const existing = await prisma.catalogItems.findUnique({ const existing = await prisma.catalogItems.findUnique({
where: { id: item.id }, where: { id: item.id },
select: { catalogName: true }, select: { catalogName: true },
}); });
if (!existing) continue; if (!existing) continue;
if (item.publicName && item.publicName !== existing.catalogName) { if (item.publicName && item.publicName !== existing.catalogName) {
await prisma.catalogItems.update({ await prisma.catalogItems.update({
where: { id: item.id }, where: { id: item.id },
data: { catalogName: item.publicName }, data: { catalogName: item.publicName },
}); });
namesUpdated++; namesUpdated++;
} }
if (item.description) { if (item.description) {
const baseItem = await prisma.itemsBase.findFirst({ const baseItem = await prisma.itemsBase.findFirst({
where: { itemName: existing.catalogName }, where: { itemName: existing.catalogName },
select: { id: true, publicName: true }, select: { id: true, publicName: true },
}); });
if (baseItem) { if (baseItem) {
await prisma.itemsBase.update({ await prisma.itemsBase.update({
where: { id: baseItem.id }, where: { id: baseItem.id },
data: { publicName: item.publicName || baseItem.publicName }, data: { publicName: item.publicName || baseItem.publicName },
}); });
furniDataUpdated++; furniDataUpdated++;
} }
descriptionsUpdated++; descriptionsUpdated++;
} }
} }
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { return {
ok: true as const, ok: true as const,
data: { data: {
namesUpdated, namesUpdated,
descriptionsUpdated, descriptionsUpdated,
furniDataUpdated, furniDataUpdated,
furniDataInserted: 0, furniDataInserted: 0,
updated: items.length, updated: items.length,
}, },
}; };
} }
+134 -125
View File
@@ -3,150 +3,159 @@
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 { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function updateCatalogPage({ export async function updateCatalogPage({
id, id,
...fields ...fields
}: { id: number } & Record<string, unknown>): Promise<ActionResult> { }: { id: number } & Record<string, unknown>): Promise<ActionResult> {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogPages.update({ where: { id }, data: fields as any }); await prisma.catalogPages.update({ where: { id }, data: fields as any });
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_page_update", action: "catalog_page_update",
description: `Updated catalog page #${id}`, description: `Updated catalog page #${id}`,
targetType: "catalog_page", targetType: "catalog_page",
targetId: id, targetId: id,
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
export async function deleteCatalogPage({ id }: { id: number }) { export async function deleteCatalogPage({ id }: { id: number }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.catalogPages.delete({ where: { id } }); await prisma.catalogPages.delete({ where: { id } });
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_page_delete", action: "catalog_page_delete",
description: `Deleted catalog page #${id}`, description: `Deleted catalog page #${id}`,
targetType: "catalog_page", targetType: "catalog_page",
targetId: id, targetId: id,
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
export async function toggleCatalogPage({ export async function toggleCatalogPage({
id, id,
action, action,
}: { }: {
id: number; id: number;
action: "toggleEnabled" | "toggleVisible"; action: "toggleEnabled" | "toggleVisible";
}) { }) {
await requireStaff(); await requireStaff();
const page = await prisma.catalogPages.findUnique({ const page = await prisma.catalogPages.findUnique({
where: { id }, where: { id },
select: { enabled: true, visible: true }, select: { enabled: true, visible: true },
}); });
if (!page) return { ok: false as const, error: "Catalog page not found" }; if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible"; const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible; const current = action === "toggleEnabled" ? page.enabled : page.visible;
await prisma.catalogPages.update({ await prisma.catalogPages.update({
where: { id }, where: { id },
data: { [field]: current === "1" ? "0" : "1" }, data: { [field]: current === "1" ? "0" : "1" },
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
} }
export async function createCatalogPage(input: { export async function createCatalogPage(input: {
caption: string; caption: string;
parentId: number; parentId: number;
pageLayout?: string; pageLayout?: string;
iconImage?: number; iconImage?: number;
iconColor?: number; iconColor?: number;
enabled?: "0" | "1"; enabled?: "0" | "1";
visible?: "0" | "1"; visible?: "0" | "1";
minRank?: number; minRank?: number;
orderNum?: number; orderNum?: number;
}): Promise<ActionResult<{ id: number }>> { }): Promise<ActionResult<{ id: number }>> {
const staff = await requireStaff(); const staff = await requireStaff();
const created = await prisma.catalogPages.create({ const created = await prisma.catalogPages.create({
data: { data: {
caption: input.caption, caption: input.caption,
parentId: input.parentId, parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3", pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25), captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0, iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0, iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1, minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0, orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1", visible: input.visible ?? "1",
enabled: input.enabled ?? "1", enabled: input.enabled ?? "1",
clubOnly: "0", clubOnly: "0",
vipOnly: "0", vipOnly: "0",
pageHeadline: "", pageHeadline: "",
pageTeaser: "", pageTeaser: "",
includes: "", includes: "",
}, },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_page_create", action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`, description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page", targetType: "catalog_page",
targetId: created.id, targetId: created.id,
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: created.id } };
} }
export async function reorderTreePage(input: { pageId: number; newParentId?: number; newOrderNum: number }) { export async function reorderTreePage(input: {
await requireStaff(); pageId: number;
await prisma.catalogPages.update({ newParentId?: number;
where: { id: input.pageId }, newOrderNum: number;
data: { }) {
orderNum: input.newOrderNum, await requireStaff();
...(input.newParentId === undefined ? {} : { parentId: input.newParentId }), await prisma.catalogPages.update({
}, where: { id: input.pageId },
}); data: {
await rcon.updateCatalog(); orderNum: input.newOrderNum,
revalidatePath("/admin/catalog"); ...(input.newParentId === undefined
return { ok: true as const, data: {} }; ? {}
: { parentId: input.newParentId }),
},
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
} }
export async function deleteTreePage(input: { pageId: number; mode: "reparent" | "cascade" }) { export async function deleteTreePage(input: {
await requireStaff(); pageId: number;
if (input.mode === "cascade") { mode: "reparent" | "cascade";
const children = await prisma.catalogPages.findMany({ }) {
where: { parentId: input.pageId }, await requireStaff();
select: { id: true }, if (input.mode === "cascade") {
}); const children = await prisma.catalogPages.findMany({
const pageIds = [input.pageId, ...children.map((child) => child.id)]; where: { parentId: input.pageId },
await prisma.$transaction([ select: { id: true },
prisma.catalogItems.deleteMany({ where: { pageId: { in: pageIds } } }), });
prisma.catalogPages.deleteMany({ where: { id: { in: pageIds } } }), const pageIds = [input.pageId, ...children.map((child) => child.id)];
]); await prisma.$transaction([
} else { prisma.catalogItems.deleteMany({ where: { pageId: { in: pageIds } } }),
const page = await prisma.catalogPages.findUnique({ prisma.catalogPages.deleteMany({ where: { id: { in: pageIds } } }),
where: { id: input.pageId }, ]);
select: { parentId: true }, } else {
}); const page = await prisma.catalogPages.findUnique({
if (!page) return { ok: false as const, error: "Catalog page not found" }; where: { id: input.pageId },
await prisma.$transaction([ select: { parentId: true },
prisma.catalogPages.updateMany({ });
where: { parentId: input.pageId }, if (!page) return { ok: false as const, error: "Catalog page not found" };
data: { parentId: page.parentId }, await prisma.$transaction([
}), prisma.catalogPages.updateMany({
prisma.catalogItems.deleteMany({ where: { pageId: input.pageId } }), where: { parentId: input.pageId },
prisma.catalogPages.delete({ where: { id: input.pageId } }), data: { parentId: page.parentId },
]); }),
} prisma.catalogItems.deleteMany({ where: { pageId: input.pageId } }),
await rcon.updateCatalog(); prisma.catalogPages.delete({ where: { id: input.pageId } }),
revalidatePath("/admin/catalog"); ]);
return { ok: true as const, data: {} }; }
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
} }
+158 -158
View File
@@ -8,218 +8,218 @@ const PATH = "/admin/commandocentrum";
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */ /** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export async function updateCatalog(): Promise<void> { export async function updateCatalog(): Promise<void> {
await requireStaff(); await requireStaff();
try { try {
await rcon.updateCatalog(); await rcon.updateCatalog();
} catch { } catch {
// RCON is best-effort; a dead socket must not 500 the admin page. // RCON is best-effort; a dead socket must not 500 the admin page.
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */ /** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export async function updateWordFilter(): Promise<void> { export async function updateWordFilter(): Promise<void> {
await requireStaff(); await requireStaff();
try { try {
await rcon.updateWordFilter(); await rcon.updateWordFilter();
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */ /** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export async function updateNavigator(): Promise<void> { export async function updateNavigator(): Promise<void> {
await requireStaff(); await requireStaff();
try { try {
await rcon.send("updatenavigator", null); await rcon.send("updatenavigator", null);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */ /** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export async function hotelAlert(formData: FormData): Promise<void> { export async function hotelAlert(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, 512); .slice(0, 512);
if (!message) return; if (!message) return;
try { try {
await rcon.send("hotelalert", { message }); await rcon.send("hotelalert", { message });
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Disconnect/kick a user from the hotel (rcon: disconnect). */ /** Disconnect/kick a user from the hotel (rcon: disconnect). */
export async function disconnectUser(formData: FormData): Promise<void> { export async function disconnectUser(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const username = String(formData.get("username") ?? "") const username = String(formData.get("username") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!userId || !username) return; if (!userId || !username) return;
try { try {
await rcon.disconnectUser(userId, username); await rcon.disconnectUser(userId, username);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Send an alert to a specific user (rcon: alertuser). */ /** Send an alert to a specific user (rcon: alertuser). */
export async function alertUser(formData: FormData): Promise<void> { export async function alertUser(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const message = String(formData.get("message") ?? "") const message = String(formData.get("message") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 512); .slice(0, 512);
if (!userId || !message) return; if (!userId || !message) return;
try { try {
await rcon.alertUser(userId, message); await rcon.alertUser(userId, message);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Forward a user to a specific room (rcon: forwarduser). */ /** Forward a user to a specific room (rcon: forwarduser). */
export async function forwardUser(formData: FormData): Promise<void> { export async function forwardUser(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const roomId = Number(formData.get("roomId")); const roomId = Number(formData.get("roomId"));
if (!userId || !roomId) return; if (!userId || !roomId) return;
try { try {
await rcon.forwardUser(userId, roomId); await rcon.forwardUser(userId, roomId);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Give credits to a user (rcon: givecredits). */ /** Give credits to a user (rcon: givecredits). */
export async function giveCredits(formData: FormData): Promise<void> { export async function giveCredits(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const credits = Number(formData.get("credits")); const credits = Number(formData.get("credits"));
if (!userId || !credits || credits <= 0) return; if (!userId || !credits || credits <= 0) return;
try { try {
await rcon.giveCredits(userId, credits); await rcon.giveCredits(userId, credits);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Give duckets to a user (rcon: givepoints type=duckets). */ /** Give duckets to a user (rcon: givepoints type=duckets). */
export async function giveDuckets(formData: FormData): Promise<void> { export async function giveDuckets(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount")); const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return; if (!userId || !amount || amount <= 0) return;
try { try {
await rcon.giveDuckets(userId, amount); await rcon.giveDuckets(userId, amount);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Give diamonds to a user (rcon: givepoints type=diamonds). */ /** Give diamonds to a user (rcon: givepoints type=diamonds). */
export async function giveDiamonds(formData: FormData): Promise<void> { export async function giveDiamonds(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount")); const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return; if (!userId || !amount || amount <= 0) return;
try { try {
await rcon.giveDiamonds(userId, amount); await rcon.giveDiamonds(userId, amount);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Give a badge to a user (rcon: givebadge). */ /** Give a badge to a user (rcon: givebadge). */
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 badge = String(formData.get("badge") ?? "") const badge = String(formData.get("badge") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!userId || !badge) return; if (!userId || !badge) return;
try { try {
await rcon.giveBadge(userId, badge); await rcon.giveBadge(userId, badge);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Set a user's motto (rcon: setmotto). */ /** Set a user's motto (rcon: setmotto). */
export async function setMotto(formData: FormData): Promise<void> { export async function setMotto(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const motto = String(formData.get("motto") ?? "") const motto = String(formData.get("motto") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 127); .slice(0, 127);
if (!userId || !motto) return; if (!userId || !motto) return;
try { try {
await rcon.setMotto(userId, motto); await rcon.setMotto(userId, motto);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Set a user's rank (rcon: setrank). */ /** Set a user's rank (rcon: setrank). */
export async function setRank(formData: FormData): Promise<void> { export async function setRank(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const rank = Number(formData.get("rank")); const rank = Number(formData.get("rank"));
if (!userId || rank < 0 || rank > 10) return; if (!userId || rank < 0 || rank > 10) return;
try { try {
await rcon.setRank(userId, rank); await rcon.setRank(userId, rank);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Execute a command as a user (rcon: executecommand). */ /** Execute a command as a user (rcon: executecommand). */
export async function executeCommand(formData: FormData): Promise<void> { export async function executeCommand(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const command = String(formData.get("command") ?? "") const command = String(formData.get("command") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!userId || !command) return; if (!userId || !command) return;
try { try {
await rcon.executeCommand(userId, command); await rcon.executeCommand(userId, command);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
/** Send a gift to a user (rcon: sendgift). */ /** Send a gift to a user (rcon: sendgift). */
export async function sendGift(formData: FormData): Promise<void> { export async function sendGift(formData: FormData): Promise<void> {
await requireStaff(); await requireStaff();
const userId = Number(formData.get("userId")); const userId = Number(formData.get("userId"));
const itemId = Number(formData.get("itemId")); const itemId = Number(formData.get("itemId"));
const message = String(formData.get("message") ?? "Here is a gift.") const message = String(formData.get("message") ?? "Here is a gift.")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!userId || !itemId) return; if (!userId || !itemId) return;
try { try {
await rcon.sendGift(userId, itemId, message); await rcon.sendGift(userId, itemId, message);
} catch { } catch {
// best-effort // best-effort
} }
revalidatePath(PATH); revalidatePath(PATH);
} }
+80 -80
View File
@@ -2,11 +2,11 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import type { Prisma } from "@/generated/prisma/client";
/** /**
* Buy a published community-drawn badge for the SIGNED-IN user. Faithful to * Buy a published community-drawn badge for the SIGNED-IN user. Faithful to
@@ -30,100 +30,100 @@ const DEFAULT_PRICE = 50;
// The emulator badge code is the badge_path filename without its directory or // The emulator badge code is the badge_path filename without its directory or
// extension, restricted to the code charset the client accepts. // extension, restricted to the code charset the client accepts.
function badgeCodeFromPath(badgePath: string): string { function badgeCodeFromPath(badgePath: string): string {
const base = badgePath.split(/[\\/]/).pop() ?? badgePath; const base = badgePath.split(/[\\/]/).pop() ?? badgePath;
const noExt = base.replace(/\.[^.]+$/, ""); const noExt = base.replace(/\.[^.]+$/, "");
return noExt.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32); return noExt.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32);
} }
async function resolvePrice(): Promise<number> { async function resolvePrice(): Promise<number> {
const raw = await siteSettings.get("drawbadge.price", String(DEFAULT_PRICE)); const raw = await siteSettings.get("drawbadge.price", String(DEFAULT_PRICE));
const n = Number(raw); const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_PRICE; return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_PRICE;
} }
export async function buyBadge(formData: FormData): Promise<void> { export async function buyBadge(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!Number.isFinite(userId)) redirect("/login"); if (!Number.isFinite(userId)) redirect("/login");
// The form posts the badge row id; everything else (price, code) is resolved // The form posts the badge row id; everything else (price, code) is resolved
// server-side from trusted data — never from the client. // server-side from trusted data — never from the client.
const rawId = String(formData.get("id") ?? "") const rawId = String(formData.get("id") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid"); if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
let outcome: "bought" | "invalid" | "credits" | "fail"; let outcome: "bought" | "invalid" | "credits" | "fail";
let boughtCode = ""; let boughtCode = "";
try { try {
const badge = await prisma.websiteDrawbadges.findUnique({ const badge = await prisma.websiteDrawbadges.findUnique({
where: { id: BigInt(rawId) }, where: { id: BigInt(rawId) },
select: { id: true, badgePath: true, published: true }, select: { id: true, badgePath: true, published: true },
}); });
if (!badge || !badge.published) { if (!badge?.published) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const code = badgeCodeFromPath(badge.badgePath); const code = badgeCodeFromPath(badge.badgePath);
if (code.length === 0) { if (code.length === 0) {
outcome = "invalid"; outcome = "invalid";
} else { } else {
const price = await resolvePrice(); const price = await resolvePrice();
// Re-read the buyer's live credit balance and verify it covers the cost. // Re-read the buyer's live credit balance and verify it covers the cost.
const buyer = await prisma.user.findUnique({ const buyer = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { credits: true }, select: { credits: true },
}); });
if (!buyer || buyer.credits < price) { if (!buyer || buyer.credits < price) {
outcome = "credits"; outcome = "credits";
} else { } else {
// Atomically deduct credits and persist the badge so a failure // Atomically deduct credits and persist the badge so a failure
// between the two operations cannot orphan the user. // between the two operations cannot orphan the user.
if (price > 0) { if (price > 0) {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.user.update({ await tx.user.update({
where: { id: userId }, where: { id: userId },
data: { credits: { decrement: price } }, data: { credits: { decrement: price } },
}); });
const existing = await tx.usersBadges.findFirst({ const existing = await tx.usersBadges.findFirst({
where: { userId, badgeCode: code }, where: { userId, badgeCode: code },
select: { id: true }, select: { id: true },
}); });
if (!existing) { if (!existing) {
const max = await tx.usersBadges.aggregate({ const max = await tx.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 tx.usersBadges.create({ await tx.usersBadges.create({
data: { userId, slotId, badgeCode: code }, data: { userId, slotId, badgeCode: code },
}); });
} }
}); });
} }
// Grant the badge live so it appears immediately for online users. // Grant the badge live so it appears immediately for online users.
await rcon.giveBadge(userId, code).catch(() => {}); await rcon.giveBadge(userId, code).catch(() => {});
outcome = "bought"; outcome = "bought";
boughtCode = code; boughtCode = code;
} }
} }
} }
} catch { } catch {
outcome = "fail"; outcome = "fail";
} }
revalidatePath("/draw-badge"); revalidatePath("/draw-badge");
// redirect() throws — it must live OUTSIDE the try/catch. // redirect() throws — it must live OUTSIDE the try/catch.
if (outcome === "bought") { if (outcome === "bought") {
redirect(`/draw-badge?bought=${encodeURIComponent(boughtCode)}`); redirect(`/draw-badge?bought=${encodeURIComponent(boughtCode)}`);
} }
redirect(`/draw-badge?error=${outcome}`); redirect(`/draw-badge?error=${outcome}`);
} }
+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
@@ -7,179 +7,189 @@ import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { import {
createEventSchema, createEventSchema,
eventPrizeSchema, eventPrizeSchema,
eventTypeSchema, eventTypeSchema,
eventWinnerSchema, eventWinnerSchema,
updateEventSchema, updateEventSchema,
} from "@/lib/validators/event"; } from "@/lib/validators/event";
// ── Event Types ───────────────────────────────────────────────────── // ── Event Types ─────────────────────────────────────────────────────
export const createEventType = adminAction( export const createEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
async (ctx) => { async (ctx) => {
const eventType = await prisma.websiteEventType.create({ const eventType = await prisma.websiteEventType.create({
data: ctx.data, data: ctx.data,
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_type_create", action: "event_type_create",
target: "WebsiteEventType", target: "WebsiteEventType",
targetId: eventType.id, targetId: eventType.id,
after: { name: eventType.name }, after: { name: eventType.name },
}); });
return actionOk({ id: eventType.id }); return actionOk({ id: eventType.id });
}, },
); );
const updateEventTypeInput = eventTypeSchema.partial().extend({ const updateEventTypeInput = eventTypeSchema.partial().extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const updateEventType = adminAction( export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteEventType.findUnique({ where: { id } }); const existing = await prisma.websiteEventType.findUnique({
if (!existing) throw new ActionError("Event type not found"); where: { id },
});
if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.update({ where: { id }, data }); await prisma.websiteEventType.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_type_update", action: "event_type_update",
target: "WebsiteEventType", target: "WebsiteEventType",
targetId: id, targetId: id,
before: { name: existing.name }, before: { name: existing.name },
after: data, after: data,
}); });
return actionOk({ id }); return actionOk({ id });
}, },
); );
const deleteEventTypeInput = z.object({ const deleteEventTypeInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const deleteEventType = adminAction( export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEventType.findUnique({ where: { id: ctx.data.id } }); const existing = await prisma.websiteEventType.findUnique({
if (!existing) throw new ActionError("Event type not found"); where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } }); await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_type_delete", action: "event_type_delete",
target: "WebsiteEventType", target: "WebsiteEventType",
targetId: ctx.data.id, targetId: ctx.data.id,
before: { name: existing.name }, before: { name: existing.name },
}); });
return actionOk(); return actionOk();
}, },
); );
// ── Events ────────────────────────────────────────────────────────── // ── Events ──────────────────────────────────────────────────────────
export const createEvent = adminAction( export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema }, { permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => { async (ctx) => {
const event = await prisma.websiteEvent.create({ const event = await prisma.websiteEvent.create({
data: { data: {
...ctx.data, ...ctx.data,
hostUserId: Number(ctx.session.user.id), hostUserId: Number(ctx.session.user.id),
}, },
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_create", action: "event_create",
target: "WebsiteEvent", target: "WebsiteEvent",
targetId: event.id, targetId: event.id,
after: { title: event.title }, after: { title: event.title },
}); });
return actionOk({ id: event.id }); return actionOk({ id: event.id });
}, },
); );
const updateEventInput = updateEventSchema.extend({ const updateEventInput = updateEventSchema.extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const updateEvent = adminAction( export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websiteEvent.findUnique({ where: { id } }); const existing = await prisma.websiteEvent.findUnique({ where: { id } });
if (!existing) throw new ActionError("Event not found"); if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.update({ where: { id }, data }); await prisma.websiteEvent.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_update", action: "event_update",
target: "WebsiteEvent", target: "WebsiteEvent",
targetId: id, targetId: id,
before: { title: existing.title, status: existing.status }, before: { title: existing.title, status: existing.status },
after: data, after: data,
}); });
return actionOk({ id }); return actionOk({ id });
}, },
); );
const deleteEventInput = z.object({ const deleteEventInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const deleteEvent = adminAction( export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websiteEvent.findUnique({ where: { id: ctx.data.id } }); const existing = await prisma.websiteEvent.findUnique({
if (!existing) throw new ActionError("Event not found"); where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } }); await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_delete", action: "event_delete",
target: "WebsiteEvent", target: "WebsiteEvent",
targetId: ctx.data.id, targetId: ctx.data.id,
before: { title: existing.title }, before: { title: existing.title },
}); });
return actionOk(); return actionOk();
}, },
); );
// ── Prizes ────────────────────────────────────────────────────────── // ── Prizes ──────────────────────────────────────────────────────────
export const addEventPrize = adminAction( export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => { async (ctx) => {
const prize = await prisma.websiteEventPrize.create({ data: ctx.data }); const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
return actionOk({ id: prize.id }); return actionOk({ id: prize.id });
}, },
); );
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() }); const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteEventPrize = adminAction( export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput }, { permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => { async (ctx) => {
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } }); await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
return actionOk(); return actionOk();
}, },
); );
// ── Winners ───────────────────────────────────────────────────────── // ── Winners ─────────────────────────────────────────────────────────
export const addEventWinner = adminAction( export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => { async (ctx) => {
const winner = await prisma.websiteEventWinner.create({ data: ctx.data }); const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "event_winner_add", action: "event_winner_add",
target: "WebsiteEventWinner", target: "WebsiteEventWinner",
targetId: winner.id, targetId: winner.id,
after: { eventId: ctx.data.eventId, userId: ctx.data.userId, position: ctx.data.position }, after: {
}); eventId: ctx.data.eventId,
return actionOk({ id: winner.id }); userId: ctx.data.userId,
}, position: ctx.data.position,
},
});
return actionOk({ id: winner.id });
},
); );
+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
>,
),
); );
+53 -40
View File
@@ -1,53 +1,66 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
export async function linkDiscordId(discordId: string): Promise<string | null> { export async function linkDiscordId(discordId: string): Promise<string | null> {
const session = await auth(); const session = await auth();
if (!session?.user?.id) return "Not logged in"; if (!session?.user?.id) return "Not logged in";
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) { if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) {
return "Invalid Discord ID format"; return "Invalid Discord ID format";
} }
const discordIdClean = discordId.trim(); const discordIdClean = discordId.trim();
try { try {
const existing = await prisma.socialAccounts.findUnique({ const existing = await prisma.socialAccounts.findUnique({
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } }, where: {
select: { userId: true }, provider_providerId: {
}); provider: "discord",
if (existing && Number(existing.userId) !== userId) { providerId: discordIdClean,
return "This Discord ID is already linked to another account"; },
} },
} catch { select: { userId: true },
return "Failed to check Discord ID"; });
} if (existing && Number(existing.userId) !== userId) {
return "This Discord ID is already linked to another account";
}
} catch {
return "Failed to check Discord ID";
}
try { try {
await prisma.socialAccounts.upsert({ await prisma.socialAccounts.upsert({
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } }, where: {
create: { provider_providerId: {
userId: BigInt(userId), provider: "discord",
provider: "discord", providerId: discordIdClean,
providerId: discordIdClean, },
createdAt: new Date(), },
updatedAt: new Date(), create: {
}, userId: BigInt(userId),
update: { userId: BigInt(userId), updatedAt: new Date() }, provider: "discord",
}); providerId: discordIdClean,
createdAt: new Date(),
updatedAt: new Date(),
},
update: { userId: BigInt(userId), updatedAt: new Date() },
});
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { mailVerified: "1" }, data: { mailVerified: "1" },
}); });
return null; return null;
} catch (e) { } catch (e) {
logger.error("Failed to link Discord account", { module: "link-discord", error: (e as Error).message }); logger.error("Failed to link Discord account", {
return "Failed to link Discord account"; module: "link-discord",
} error: (e as Error).message,
});
return "Failed to link Discord account";
}
} }
+46 -46
View File
@@ -18,58 +18,58 @@ import { prisma } from "@/lib/prisma";
* longer shows as pending in the in-game messenger or here. * longer shows as pending in the in-game messenger or here.
*/ */
export async function acceptFriend(formData: FormData): Promise<void> { export async function acceptFriend(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
const meId = Number(session?.user?.id); const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) return; if (!Number.isInteger(meId) || meId <= 0) return;
const requestId = Number(formData.get("requestId")); const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) return; if (!Number.isInteger(requestId) || requestId <= 0) return;
try { try {
// The request must exist AND be addressed to the session user. // The request must exist AND be addressed to the session user.
const request = await prisma.messengerFriendrequests.findUnique({ const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId }, where: { id: requestId },
select: { id: true, userFromId: true, userToId: true }, select: { id: true, userFromId: true, userToId: true },
}); });
if (!request || request.userToId !== meId) return; if (!request || request.userToId !== meId) return;
const friendId = request.userFromId; const friendId = request.userFromId;
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
// Malformed/self request — just clear it. // Malformed/self request — just clear it.
await prisma.messengerFriendrequests.delete({ where: { id: requestId } }); await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
return; return;
} }
const friendsSince = Math.floor(Date.now() / 1000); const friendsSince = Math.floor(Date.now() / 1000);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Don't double-insert if a friendship already exists in either direction. // Don't double-insert if a friendship already exists in either direction.
const existing = await tx.messengerFriendships.findFirst({ const existing = await tx.messengerFriendships.findFirst({
where: { where: {
OR: [ OR: [
{ userOneId: meId, userTwoId: friendId }, { userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId }, { userOneId: friendId, userTwoId: meId },
], ],
}, },
select: { id: true }, select: { id: true },
}); });
if (!existing) { if (!existing) {
await tx.messengerFriendships.createMany({ await tx.messengerFriendships.createMany({
data: [ data: [
{ userOneId: meId, userTwoId: friendId, friendsSince }, { userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince }, { userOneId: friendId, userTwoId: meId, friendsSince },
], ],
}); });
} }
await tx.messengerFriendrequests.delete({ where: { id: requestId } }); await tx.messengerFriendrequests.delete({ where: { id: requestId } });
}); });
} catch { } catch {
// DB unavailable — fail soft; nothing to persist. // DB unavailable — fail soft; nothing to persist.
return; return;
} }
revalidatePath("/messages"); revalidatePath("/messages");
revalidatePath("/friends"); revalidatePath("/friends");
} }
+134 -130
View File
@@ -1,10 +1,10 @@
"use server"; "use server";
import { z } from "zod"; import { z } from "zod";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { adminAction, actionOk } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -13,73 +13,77 @@ import { rcon } from "@/lib/services/rcon";
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() }); const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
export const assignCfhTicket = adminAction( export const assignCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }); const ticket = await prisma.supportTickets.findUnique({
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); where: { id: ctx.data.ticketId },
});
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await prisma.supportTickets.update({ await prisma.supportTickets.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { modId: ctx.session.user.id, state: 1 }, data: { modId: ctx.session.user.id, state: 1 },
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "cfh_assign", action: "cfh_assign",
target: "support_tickets", target: "support_tickets",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
}); });
return actionOk(); return actionOk();
}, },
); );
const cfhStateSchema = z.object({ const cfhStateSchema = z.object({
ticketId: z.coerce.number().int().positive(), ticketId: z.coerce.number().int().positive(),
state: z.coerce.number().int().min(0).max(3), state: z.coerce.number().int().min(0).max(3),
}); });
export const updateCfhState = adminAction( export const updateCfhState = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }); const ticket = await prisma.supportTickets.findUnique({
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); where: { id: ctx.data.ticketId },
});
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await prisma.supportTickets.update({ await prisma.supportTickets.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { state: ctx.data.state, modId: ctx.session.user.id }, data: { state: ctx.data.state, modId: ctx.session.user.id },
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "cfh_state_change", action: "cfh_state_change",
target: "support_tickets", target: "support_tickets",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { state: ticket.state }, before: { state: ticket.state },
after: { state: ctx.data.state }, after: { state: ctx.data.state },
}); });
return actionOk(); return actionOk();
}, },
); );
export const closeCfhTicket = adminAction( export const closeCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
async (ctx) => { async (ctx) => {
await prisma.supportTickets.update({ await prisma.supportTickets.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { state: 2, modId: ctx.session.user.id }, data: { state: 2, modId: ctx.session.user.id },
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "cfh_close", action: "cfh_close",
target: "support_tickets", target: "support_tickets",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
}); });
return actionOk(); return actionOk();
}, },
); );
// ── Quick Mod Actions ──────────────────────────────────────────────── // ── Quick Mod Actions ────────────────────────────────────────────────
@@ -87,120 +91,120 @@ export const closeCfhTicket = adminAction(
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() }); const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction( export const quickKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
async (ctx) => { async (ctx) => {
await rcon.disconnectUser(ctx.data.userId); await rcon.disconnectUser(ctx.data.userId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "mod_kick", action: "mod_kick",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}); });
return actionOk(); return actionOk();
}, },
); );
const muteSchema = z.object({ const muteSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).max(525600), duration: z.coerce.number().int().min(0).max(525600),
}); });
export const quickMute = adminAction( export const quickMute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema }, { permission: PERMS.MODERATION_EDIT, schema: muteSchema },
async (ctx) => { async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration); await rcon.muteUser(ctx.data.userId, ctx.data.duration);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "mod_mute", action: "mod_mute",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { duration: ctx.data.duration }, after: { duration: ctx.data.duration },
}); });
return actionOk(); return actionOk();
}, },
); );
export const quickUnmute = adminAction( export const quickUnmute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
async (ctx) => { async (ctx) => {
await rcon.unmuteUser(ctx.data.userId); await rcon.unmuteUser(ctx.data.userId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "mod_unmute", action: "mod_unmute",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}); });
return actionOk(); return actionOk();
}, },
); );
const alertSchema = z.object({ const alertSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500), message: z.string().min(1).max(500),
}); });
export const quickAlert = adminAction( export const quickAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema }, { permission: PERMS.MODERATION_EDIT, schema: alertSchema },
async (ctx) => { async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message); await rcon.alertUser(ctx.data.userId, ctx.data.message);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "mod_alert", action: "mod_alert",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { message: ctx.data.message }, after: { message: ctx.data.message },
}); });
return actionOk(); return actionOk();
}, },
); );
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() }); const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction( export const quickRoomKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema }, { permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
async (ctx) => { async (ctx) => {
await rcon.kickAll(ctx.data.roomId); await rcon.kickAll(ctx.data.roomId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "mod_room_kick", action: "mod_room_kick",
target: "Room", target: "Room",
targetId: ctx.data.roomId, targetId: ctx.data.roomId,
}); });
return actionOk(); return actionOk();
}, },
); );
const broadcastSchema = z.object({ const broadcastSchema = z.object({
message: z.string().min(1).max(500), message: z.string().min(1).max(500),
type: z.enum(["hotel", "staff"]), type: z.enum(["hotel", "staff"]),
}); });
export const broadcastAlert = adminAction( export const broadcastAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema }, { permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
async (ctx) => { async (ctx) => {
if (ctx.data.type === "hotel") { if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message); await rcon.hotelAlert(ctx.data.message);
} else { } else {
await rcon.staffAlert(ctx.data.message); await rcon.staffAlert(ctx.data.message);
} }
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: `mod_broadcast_${ctx.data.type}`, action: `mod_broadcast_${ctx.data.type}`,
target: "broadcast", target: "broadcast",
after: { message: ctx.data.message }, after: { message: ctx.data.message },
}); });
return actionOk(); return actionOk();
}, },
); );
+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 } };
} }
+63 -52
View File
@@ -1,85 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } = const {
vi.hoisted(() => ({ mockFindFirst,
mockFindFirst: vi.fn(), mockUpsert,
mockUpsert: vi.fn(), mockFindUnique,
mockFindUnique: vi.fn(), mockUpdate,
mockUpdate: vi.fn(), mockDelete,
mockDelete: vi.fn(), mockSendMail,
mockSendMail: vi.fn(), mockRedirect,
mockRedirect: vi.fn(), } = vi.hoisted(() => ({
})); mockFindFirst: vi.fn(),
mockUpsert: vi.fn(),
mockFindUnique: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockSendMail: vi.fn(),
mockRedirect: vi.fn(),
}));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: (...args: unknown[]) => { redirect: (...args: unknown[]) => {
mockRedirect(...args); mockRedirect(...args);
throw new Error("redirect"); throw new Error("redirect");
}, },
})); }));
vi.mock("@/lib/prisma", () => ({ vi.mock("@/lib/prisma", () => ({
prisma: { prisma: {
user: { findFirst: mockFindFirst, update: mockUpdate }, user: { findFirst: mockFindFirst, update: mockUpdate },
passwordReset: { upsert: mockUpsert, findUnique: mockFindUnique, delete: mockDelete }, passwordReset: {
}, upsert: mockUpsert,
findUnique: mockFindUnique,
delete: mockDelete,
},
},
})); }));
vi.mock("@/lib/services/email", () => ({ vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail, sendMail: mockSendMail,
})); }));
vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/rate-limit", () => ({
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }), rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
clientIp: vi.fn().mockResolvedValue("127.0.0.1"), clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
})); }));
vi.mock("@/env", () => ({ vi.mock("@/env", () => ({
env: { APP_URL: "http://localhost:3000", HOTEL_NAME: "TestHotel" }, env: { APP_URL: "http://localhost:3000", HOTEL_NAME: "TestHotel" },
})); }));
import { requestReset } from "./password-reset"; import { requestReset } from "./password-reset";
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("requestReset", () => { describe("requestReset", () => {
it("sends a reset email when the user exists", async () => { it("sends a reset email when the user exists", async () => {
mockFindFirst.mockResolvedValue({ id: 1 }); mockFindFirst.mockResolvedValue({ id: 1 });
mockUpsert.mockResolvedValue({}); mockUpsert.mockResolvedValue({});
const fd = new FormData(); const fd = new FormData();
fd.set("email", "[email protected]"); fd.set("email", "[email protected]");
await expect(requestReset(fd)).rejects.toThrow("redirect"); await expect(requestReset(fd)).rejects.toThrow("redirect");
expect(mockFindFirst).toHaveBeenCalledWith( expect(mockFindFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { mail: "[email protected]" } }), expect.objectContaining({ where: { mail: "[email protected]" } }),
); );
expect(mockUpsert).toHaveBeenCalled(); expect(mockUpsert).toHaveBeenCalled();
expect(mockSendMail).toHaveBeenCalledWith( expect(mockSendMail).toHaveBeenCalledWith(
"[email protected]", "[email protected]",
expect.stringContaining("password reset"), expect.stringContaining("password reset"),
expect.stringContaining("http://localhost:3000/reset"), expect.stringContaining("http://localhost:3000/reset"),
); );
}); });
it("does not send email when user is not found", async () => { it("does not send email when user is not found", async () => {
mockFindFirst.mockResolvedValue(null); mockFindFirst.mockResolvedValue(null);
const fd = new FormData(); const fd = new FormData();
fd.set("email", "[email protected]"); fd.set("email", "[email protected]");
await expect(requestReset(fd)).rejects.toThrow("redirect"); await expect(requestReset(fd)).rejects.toThrow("redirect");
expect(mockSendMail).not.toHaveBeenCalled(); expect(mockSendMail).not.toHaveBeenCalled();
}); });
it("rate limits and does not throw on email without @", async () => { it("rate limits and does not throw on email without @", async () => {
const fd = new FormData(); const fd = new FormData();
fd.set("email", "not-an-email"); fd.set("email", "not-an-email");
await expect(requestReset(fd)).rejects.toThrow("redirect"); await expect(requestReset(fd)).rejects.toThrow("redirect");
expect(mockFindFirst).not.toHaveBeenCalled(); expect(mockFindFirst).not.toHaveBeenCalled();
}); });
}); });
+93 -81
View File
@@ -2,104 +2,116 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { hashPassword } from "@/lib/auth/password";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { sendMail } from "@/lib/services/email";
import { env } from "@/env"; import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { sendMail } from "@/lib/services/email";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
function sha256(s: string): string { function sha256(s: string): string {
return createHash("sha256").update(s).digest("hex"); return createHash("sha256").update(s).digest("hex");
} }
export async function requestReset(formData: FormData): Promise<void> { export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "") const email = String(formData.get("email") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.toLowerCase(); .toLowerCase();
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse. // Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)).ok; const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000))
.ok;
// Always respond the same way so we don't reveal which emails exist. // Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try { try {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } }); const user = await prisma.user.findFirst({
if (user) { where: { mail: email },
const token = randomBytes(32).toString("hex"); select: { id: true },
await prisma.passwordReset.upsert({ });
where: { email }, if (user) {
update: { token: sha256(token), createdAt: new Date() }, const token = randomBytes(32).toString("hex");
create: { email, token: sha256(token), createdAt: new Date() }, await prisma.passwordReset.upsert({
}); where: { email },
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`; update: { token: sha256(token), createdAt: new Date() },
await sendMail( create: { email, token: sha256(token), createdAt: new Date() },
email, });
`${env.HOTEL_NAME} — password reset`, const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
`<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`, await sendMail(
); email,
} `${env.HOTEL_NAME} — password reset`,
} catch { `<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`,
// swallow — generic response below );
} }
} } catch {
// swallow — generic response below
}
}
redirect("/forgot?sent=1"); redirect("/forgot?sent=1");
} }
export async function resetPassword(formData: FormData): Promise<void> { export async function resetPassword(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "") const email = String(formData.get("email") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.toLowerCase(); .toLowerCase();
const token = String(formData.get("token") ?? "") const token = String(formData.get("token") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const password = String(formData.get("password") ?? "").normalize("NFC"); const password = String(formData.get("password") ?? "").normalize("NFC");
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force. // Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) { if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
redirect( redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`, `/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
); );
} }
let error: string | null = null; let error: string | null = null;
if (password.length < 6) error = "Password must be at least 6 characters"; if (password.length < 6) error = "Password must be at least 6 characters";
if (!error) { if (!error) {
try { try {
const row = await prisma.passwordReset.findUnique({ where: { email } }); const row = await prisma.passwordReset.findUnique({ where: { email } });
const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false; const fresh = row?.createdAt
const a = Buffer.from(sha256(token), "hex"); ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length); : false;
const match = row != null && a.length === b.length && timingSafeEqual(a, b); const a = Buffer.from(sha256(token), "hex");
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
const match =
row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) { if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired"; error = "This reset link is invalid or has expired";
} else { } else {
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } }); const user = await prisma.user.findFirst({
if (!user) { where: { mail: email },
error = "Account not found"; select: { id: true },
} else { });
await prisma.user.update({ if (!user) {
where: { id: user.id }, error = "Account not found";
data: { password: await hashPassword(password) }, } else {
}); await prisma.user.update({
await prisma.passwordReset.delete({ where: { email } }).catch(() => {}); where: { id: user.id },
} data: { password: await hashPassword(password) },
} });
} catch { await prisma.passwordReset
error = "Could not reset the password — try again"; .delete({ where: { email } })
} .catch(() => {});
} }
}
} catch {
error = "Could not reset the password — try again";
}
}
if (error) { if (error) {
redirect( redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`, `/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
); );
} }
redirect("/login?reset=1"); redirect("/login?reset=1");
} }
+118 -107
View File
@@ -6,135 +6,146 @@ import { PERMS } from "@/lib/permission-slugs";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from "@/lib/services/permission-ranks"; import {
createEmulatorRank,
deleteEmulatorRank,
updateEmulatorRank,
} from "@/lib/services/permission-ranks";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
const createRankSchema = z.object({ const createRankSchema = z.object({
rank_name: z.string().trim().min(1).max(25), rank_name: z.string().trim().min(1).max(25),
level: z.coerce.number().int().min(1), level: z.coerce.number().int().min(1),
}); });
export const createRank = adminAction( export const createRank = adminAction(
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
const id = await createEmulatorRank(prisma, ctx.data); const id = await createEmulatorRank(prisma, ctx.data);
await prisma.aclRole.upsert({ await prisma.aclRole.upsert({
where: { slug: `rank_${id}` }, where: { slug: `rank_${id}` },
create: { create: {
slug: `rank_${id}`, slug: `rank_${id}`,
title: ctx.data.rank_name, title: ctx.data.rank_name,
description: "CMS role synchronized from permission_ranks", description: "CMS role synchronized from permission_ranks",
}, },
update: { title: ctx.data.rank_name }, update: { title: ctx.data.rank_name },
}); });
await logStaffActivity({ await logStaffActivity({
staffId: ctx.session.user.id, staffId: ctx.session.user.id,
action: "rank_create", action: "rank_create",
description: `Created rank #${id}`, description: `Created rank #${id}`,
targetType: "rank", targetType: "rank",
targetId: id, targetId: id,
}); });
await rcon.send("updatepermissions"); await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 }); revalidateTag("permissions", { expire: 0 });
return actionOk({ id }); return actionOk({ id });
}, },
); );
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() }); const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
export const deleteRank = adminAction( export const deleteRank = adminAction(
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
const users = await prisma.user.count({ where: { rank: ctx.data.id } }); const users = await prisma.user.count({ where: { rank: ctx.data.id } });
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`); if (users > 0)
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } }); throw new ActionError(`Cannot delete: ${users} users have this rank`);
await deleteEmulatorRank(prisma, ctx.data.id); const role = await prisma.aclRole.findFirst({
if (role) { where: { slug: `rank_${ctx.data.id}` },
await prisma.$transaction([ });
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }), await deleteEmulatorRank(prisma, ctx.data.id);
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }), if (role) {
prisma.aclRole.delete({ where: { id: role.id } }), await prisma.$transaction([
]); prisma.aclModelPermission.deleteMany({
} where: { modelId: role.id, modelType: "Role" },
await logStaffActivity({ }),
staffId: ctx.session.user.id, prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
action: "rank_delete", prisma.aclRole.delete({ where: { id: role.id } }),
description: `Deleted rank #${ctx.data.id}`, ]);
targetType: "rank", }
targetId: ctx.data.id, await logStaffActivity({
}); staffId: ctx.session.user.id,
await rcon.send("updatepermissions"); action: "rank_delete",
revalidateTag("permissions", { expire: 0 }); description: `Deleted rank #${ctx.data.id}`,
return actionOk(); targetType: "rank",
}, targetId: ctx.data.id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
); );
const saveRankSchema = z.object({ const saveRankSchema = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
fields: z.record(z.string(), z.union([z.string(), z.number()])), fields: z.record(z.string(), z.union([z.string(), z.number()])),
}); });
export const saveRank = adminAction( export const saveRank = adminAction(
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields); await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
if (typeof ctx.data.fields.rank_name === "string") { if (typeof ctx.data.fields.rank_name === "string") {
await prisma.aclRole.updateMany({ await prisma.aclRole.updateMany({
where: { slug: `rank_${ctx.data.id}` }, where: { slug: `rank_${ctx.data.id}` },
data: { title: ctx.data.fields.rank_name }, data: { title: ctx.data.fields.rank_name },
}); });
} }
await logStaffActivity({ await logStaffActivity({
staffId: ctx.session.user.id, staffId: ctx.session.user.id,
action: "rank_update", action: "rank_update",
description: `Updated rank #${ctx.data.id}`, description: `Updated rank #${ctx.data.id}`,
targetType: "rank", targetType: "rank",
targetId: ctx.data.id, targetId: ctx.data.id,
}); });
await rcon.send("updatepermissions"); await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 }); revalidateTag("permissions", { expire: 0 });
return actionOk(); return actionOk();
}, },
); );
const setCmsPermsSchema = z.object({ const setCmsPermsSchema = z.object({
roleId: z.coerce.number().int().positive(), roleId: z.coerce.number().int().positive(),
permissionSlugs: z.array(z.string().trim().min(1)).max(500), permissionSlugs: z.array(z.string().trim().min(1)).max(500),
}); });
export const setCmsPermissions = adminAction( export const setCmsPermissions = adminAction(
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE }, { schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => { async (ctx) => {
const role = await prisma.aclRole.findUnique({ const role = await prisma.aclRole.findUnique({
where: { id: ctx.data.roleId }, where: { id: ctx.data.roleId },
select: { id: true, slug: true }, select: { id: true, slug: true },
}); });
if (!role) throw new ActionError("Role not found"); if (!role) throw new ActionError("Role not found");
const permissions = await prisma.aclPermission.findMany({ const permissions = await prisma.aclPermission.findMany({
where: { slug: { in: ctx.data.permissionSlugs } }, where: { slug: { in: ctx.data.permissionSlugs } },
select: { id: true }, select: { id: true },
}); });
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }); await tx.aclModelPermission.deleteMany({
if (permissions.length) { where: { modelId: role.id, modelType: "Role" },
await tx.aclModelPermission.createMany({ });
data: permissions.map((permission) => ({ if (permissions.length) {
modelId: role.id, await tx.aclModelPermission.createMany({
modelType: "Role", data: permissions.map((permission) => ({
permissionId: permission.id, modelId: role.id,
})), modelType: "Role",
}); permissionId: permission.id,
} })),
}); });
await logStaffActivity({ }
staffId: ctx.session.user.id, });
action: "acl_role_permissions_update", await logStaffActivity({
description: `Updated ${permissions.length} permissions for ${role.slug}`, staffId: ctx.session.user.id,
targetType: "acl_role", action: "acl_role_permissions_update",
targetId: role.id, description: `Updated ${permissions.length} permissions for ${role.slug}`,
}); targetType: "acl_role",
revalidateTag("permissions", { expire: 0 }); targetId: role.id,
return actionOk(); });
}, revalidateTag("permissions", { expire: 0 });
return actionOk();
},
); );
+71 -63
View File
@@ -6,102 +6,110 @@ import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { createPollSchema, pollQuestionSchema, updatePollSchema } from "@/lib/validators/poll"; import {
createPollSchema,
pollQuestionSchema,
updatePollSchema,
} from "@/lib/validators/poll";
// ── Polls ─────────────────────────────────────────────────────────── // ── Polls ───────────────────────────────────────────────────────────
export const createPoll = adminAction( export const createPoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema }, { permission: PERMS.POLLS_EDIT, schema: createPollSchema },
async (ctx) => { async (ctx) => {
const poll = await prisma.websitePoll.create({ data: ctx.data }); const poll = await prisma.websitePoll.create({ data: ctx.data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "poll_create", action: "poll_create",
target: "WebsitePoll", target: "WebsitePoll",
targetId: poll.id, targetId: poll.id,
after: { title: poll.title }, after: { title: poll.title },
}); });
return actionOk({ id: poll.id }); return actionOk({ id: poll.id });
}, },
); );
const updatePollInput = updatePollSchema.extend({ const updatePollInput = updatePollSchema.extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const updatePoll = adminAction( export const updatePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput }, { permission: PERMS.POLLS_EDIT, schema: updatePollInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const existing = await prisma.websitePoll.findUnique({ where: { id } }); const existing = await prisma.websitePoll.findUnique({ where: { id } });
if (!existing) throw new ActionError("Poll not found"); if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.update({ where: { id }, data }); await prisma.websitePoll.update({ where: { id }, data });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "poll_update", action: "poll_update",
target: "WebsitePoll", target: "WebsitePoll",
targetId: id, targetId: id,
before: { title: existing.title, status: existing.status }, before: { title: existing.title, status: existing.status },
after: data, after: data,
}); });
return actionOk({ id }); return actionOk({ id });
}, },
); );
const deletePollInput = z.object({ const deletePollInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const deletePoll = adminAction( export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput }, { permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => { async (ctx) => {
const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } }); const existing = await prisma.websitePoll.findUnique({
if (!existing) throw new ActionError("Poll not found"); where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Poll not found");
await prisma.websitePoll.delete({ where: { id: ctx.data.id } }); await prisma.websitePoll.delete({ where: { id: ctx.data.id } });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "poll_delete", action: "poll_delete",
target: "WebsitePoll", target: "WebsitePoll",
targetId: ctx.data.id, targetId: ctx.data.id,
before: { title: existing.title }, before: { title: existing.title },
}); });
return actionOk(); return actionOk();
}, },
); );
// ── Questions ─────────────────────────────────────────────────────── // ── Questions ───────────────────────────────────────────────────────
export const addPollQuestion = adminAction( export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => { async (ctx) => {
const question = await prisma.websitePollQuestion.create({ data: ctx.data }); const question = await prisma.websitePollQuestion.create({
return actionOk({ id: question.id }); data: ctx.data,
}, });
return actionOk({ id: question.id });
},
); );
const updateQuestionInput = pollQuestionSchema.partial().extend({ const updateQuestionInput = pollQuestionSchema.partial().extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const updatePollQuestion = adminAction( export const updatePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
await prisma.websitePollQuestion.update({ where: { id }, data }); await prisma.websitePollQuestion.update({ where: { id }, data });
return actionOk({ id }); return actionOk({ id });
}, },
); );
const deleteQuestionInput = z.object({ const deleteQuestionInput = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const deletePollQuestion = adminAction( export const deletePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
async (ctx) => { async (ctx) => {
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } }); await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } });
return actionOk(); return actionOk();
}, },
); );
+60 -60
View File
@@ -13,130 +13,130 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
// ── Create prefix ─────────────────────────────────────────────────── // ── Create prefix ───────────────────────────────────────────────────
const createPrefixSchema = z.object({ const createPrefixSchema = z.object({
username: z.string().min(1), username: z.string().min(1),
text: z.string().min(1), text: z.string().min(1),
color: z.string().min(1), color: z.string().min(1),
icon: z.string().optional(), icon: z.string().optional(),
effect: z.string().optional(), effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).default(1), active: z.coerce.number().int().min(0).max(1).default(1),
}); });
export const createPrefix = adminAction( export const createPrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, { permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
async (ctx) => { async (ctx) => {
const { username, text, color, icon, effect, active } = ctx.data; const { username, text, color, icon, effect, active } = ctx.data;
const users = await prisma.$queryRaw<{ id: number }[]>` const users = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM users WHERE username = ${username} LIMIT 1 SELECT id FROM users WHERE username = ${username} LIMIT 1
`; `;
if (users.length === 0) throw new ActionError("User not found"); if (users.length === 0) throw new ActionError("User not found");
await prisma.$executeRaw` await prisma.$executeRaw`
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
VALUES (${users[0].id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active}) VALUES (${users[0].id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
`; `;
return actionOk(); return actionOk();
}, },
); );
// ── Update prefix ─────────────────────────────────────────────────── // ── Update prefix ───────────────────────────────────────────────────
const updatePrefixSchema = z.object({ const updatePrefixSchema = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
text: z.string().min(1), text: z.string().min(1),
color: z.string().min(1), color: z.string().min(1),
icon: z.string().optional(), icon: z.string().optional(),
effect: z.string().optional(), effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).optional(), active: z.coerce.number().int().min(0).max(1).optional(),
}); });
export const updatePrefix = adminAction( export const updatePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
async (ctx) => { async (ctx) => {
const { id, text, color, icon, effect, active } = ctx.data; const { id, text, color, icon, effect, active } = ctx.data;
await prisma.$executeRaw` await prisma.$executeRaw`
UPDATE custom_prefixes UPDATE custom_prefixes
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1} SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
WHERE id = ${id} WHERE id = ${id}
`; `;
return actionOk(); return actionOk();
}, },
); );
// ── Delete prefix ─────────────────────────────────────────────────── // ── Delete prefix ───────────────────────────────────────────────────
const deletePrefixSchema = z.object({ const deletePrefixSchema = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const deletePrefix = adminAction( export const deletePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, { permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`; await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`;
return actionOk(); return actionOk();
}, },
); );
// ── Add blacklist word ────────────────────────────────────────────── // ── Add blacklist word ──────────────────────────────────────────────
const addBlacklistWordSchema = z.object({ const addBlacklistWordSchema = z.object({
word: z.string().min(1).max(100), word: z.string().min(1).max(100),
}); });
export const addBlacklistWord = adminAction( export const addBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema }, { permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw` await prisma.$executeRaw`
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()}) INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
`; `;
return actionOk(); return actionOk();
}, },
); );
// ── Remove blacklist word ─────────────────────────────────────────── // ── Remove blacklist word ───────────────────────────────────────────
const removeBlacklistWordSchema = z.object({ const removeBlacklistWordSchema = z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const removeBlacklistWord = adminAction( export const removeBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema }, { permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
async (ctx) => { async (ctx) => {
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`; await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`;
return actionOk(); return actionOk();
}, },
); );
// ── Update prefix settings ────────────────────────────────────────── // ── Update prefix settings ──────────────────────────────────────────
const SETTINGS_WHITELIST = new Set([ const SETTINGS_WHITELIST = new Set([
"enabled", "enabled",
"max_length", "max_length",
"min_rank", "min_rank",
"allow_colors", "allow_colors",
"allow_bold", "allow_bold",
"allow_italic", "allow_italic",
"default_color", "default_color",
]); ]);
const updatePrefixSettingsSchema = z.object({ const updatePrefixSettingsSchema = z.object({
settings: z.record(z.string(), z.string()), settings: z.record(z.string(), z.string()),
}); });
export const updatePrefixSettings = adminAction( export const updatePrefixSettings = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema }, { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
async (ctx) => { async (ctx) => {
for (const [key, value] of Object.entries(ctx.data.settings)) { for (const [key, value] of Object.entries(ctx.data.settings)) {
if (!SETTINGS_WHITELIST.has(key)) continue; if (!SETTINGS_WHITELIST.has(key)) continue;
await prisma.$executeRaw` await prisma.$executeRaw`
INSERT INTO custom_prefix_settings (\`key\`, \`value\`) INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
VALUES (${key}, ${value}) VALUES (${key}, ${value})
ON DUPLICATE KEY UPDATE \`value\` = ${value} ON DUPLICATE KEY UPDATE \`value\` = ${value}
`; `;
} }
return actionOk(); return actionOk();
}, },
); );
+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
@@ -27,121 +27,143 @@ import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its * redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
* internal control-flow throw is never swallowed. * internal control-flow throw is never swallowed.
*/ */
const VALID_CURRENCIES = new Set<CurrencyName>(["credits", "duckets", "diamonds", "points"]); const VALID_CURRENCIES = new Set<CurrencyName>([
"credits",
"duckets",
"diamonds",
"points",
]);
export async function claimReferral(_formData: FormData): Promise<void> { export async function claimReferral(_formData: FormData): Promise<void> {
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" = "error"; let outcome:
| "claimed"
| "not_enough"
| "no_referrals"
| "bad_config"
| "error" = "error";
try { try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
redirect("/login"); redirect("/login");
} }
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!Number.isFinite(userId) || userId <= 0) { if (!Number.isFinite(userId) || userId <= 0) {
redirect("/login"); redirect("/login");
} }
// Reward configuration (CMS-owned website_settings). AtomCMS defaults: // Reward configuration (CMS-owned website_settings). AtomCMS defaults:
// 5 referrals needed, 30 diamonds reward. // 5 referrals needed, 30 diamonds reward.
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([ const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
prisma.websiteSetting prisma.websiteSetting
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } }) .findUnique({
.catch(() => null), where: { key: "referrals_needed" },
prisma.websiteSetting select: { value: true },
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } }) })
.catch(() => null), .catch(() => null),
// The seeded key is referral_reward_currency_type; fall back to the prisma.websiteSetting
// shorter referral_reward_currency name if that is what is configured. .findUnique({
prisma.websiteSetting where: { key: "referral_reward_amount" },
.findFirst({ select: { value: true },
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } }, })
select: { value: true }, .catch(() => null),
}) // The seeded key is referral_reward_currency_type; fall back to the
.catch(() => null), // shorter referral_reward_currency name if that is what is configured.
]); prisma.websiteSetting
.findFirst({
where: {
key: {
in: ["referral_reward_currency_type", "referral_reward_currency"],
},
},
select: { value: true },
})
.catch(() => null),
]);
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5; const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
const amount = Number.parseInt(amountRaw?.value ?? "30", 10); const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
const currency = (currencyRaw?.value ?? "diamonds").trim().toLowerCase() as CurrencyName; const currency = (currencyRaw?.value ?? "diamonds")
.trim()
.toLowerCase() as CurrencyName;
// The user's referral tally lives in user_referrals (one row per user). // The user's referral tally lives in user_referrals (one row per user).
const referrals = await prisma.userReferrals const referrals = await prisma.userReferrals
.findFirst({ .findFirst({
where: { userId }, where: { userId },
select: { id: true, referralsTotal: true }, select: { id: true, referralsTotal: true },
orderBy: { id: "desc" }, orderBy: { id: "desc" },
}) })
.catch(() => null); .catch(() => null);
const total = referrals ? Number(referrals.referralsTotal) : 0; const total = referrals ? Number(referrals.referralsTotal) : 0;
if (!referrals || total <= 0) { if (!referrals || total <= 0) {
outcome = "no_referrals"; outcome = "no_referrals";
} else if (total < needed) { } else if (total < needed) {
outcome = "not_enough"; outcome = "not_enough";
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) { } else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
// Misconfigured reward — keep it conservative and grant nothing. // Misconfigured reward — keep it conservative and grant nothing.
outcome = "bad_config"; outcome = "bad_config";
} else { } else {
// Spend the threshold first so a concurrent double-submit can't claim // Spend the threshold first so a concurrent double-submit can't claim
// twice off the same balance, then deliver the reward and log it. // twice off the same balance, then deliver the reward and log it.
await prisma.userReferrals.update({ await prisma.userReferrals.update({
where: { id: referrals.id }, where: { id: referrals.id },
data: { referralsTotal: { decrement: needed } }, data: { referralsTotal: { decrement: needed } },
}); });
try { try {
await sendCurrency({ rcon, db: prisma }, userId, currency, amount); await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
} catch { } catch {
// sendCurrency already falls back to a direct DB write; if it still // sendCurrency already falls back to a direct DB write; if it still
// throws the spend stands. Roll the threshold back so the user isn't // throws the spend stands. Roll the threshold back so the user isn't
// charged for an undelivered reward. // charged for an undelivered reward.
await prisma.userReferrals await prisma.userReferrals
.update({ .update({
where: { id: referrals.id }, where: { id: referrals.id },
data: { referralsTotal: { increment: needed } }, data: { referralsTotal: { increment: needed } },
}) })
.catch(() => {}); .catch(() => {});
outcome = "error"; outcome = "error";
throw new Error("currency-delivery-failed"); throw new Error("currency-delivery-failed");
} }
await prisma.claimedReferralLogs await prisma.claimedReferralLogs
.create({ .create({
data: { data: {
userId, userId,
ipAddress: await clientIp(), ipAddress: await clientIp(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}, },
}) })
.catch(() => { .catch(() => {
// Best-effort audit log; the reward already landed. // Best-effort audit log; the reward already landed.
}); });
outcome = "claimed"; outcome = "claimed";
} }
} catch (err) { } catch (err) {
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the // redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
// navigation actually happens instead of being treated as a failure. // navigation actually happens instead of being treated as a failure.
if ( if (
err && err &&
typeof err === "object" && typeof err === "object" &&
"digest" in err && "digest" in err &&
typeof (err as { digest?: unknown }).digest === "string" && typeof (err as { digest?: unknown }).digest === "string" &&
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT") (err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) { ) {
throw err; throw err;
} }
if (outcome === "claimed") outcome = "error"; if (outcome === "claimed") outcome = "error";
} }
revalidatePath("/me"); revalidatePath("/me");
if (outcome === "claimed") { if (outcome === "claimed") {
redirect("/me?claimed=1"); redirect("/me?claimed=1");
} }
redirect(`/me?error=${outcome}`); redirect(`/me?error=${outcome}`);
} }
+110 -99
View File
@@ -11,118 +11,129 @@ import { checkVpn } from "@/lib/services/ip-lookup";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const registerSchema = z.object({ const registerSchema = z.object({
username: z username: z
.string() .string()
.min(3, "Username must be at least 3 characters") .min(3, "Username must be at least 3 characters")
.max(25, "Username must be at most 25 characters") .max(25, "Username must be at most 25 characters")
.regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"), .regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"),
mail: z.string().email("Enter a valid email address").optional().or(z.literal("")), mail: z
password: z .string()
.string() .email("Enter a valid email address")
.min(8, "Password must be at least 8 characters") .optional()
.regex(/[A-Z]/, "Password must contain at least one uppercase letter") .or(z.literal("")),
.regex(/[a-z]/, "Password must contain at least one lowercase letter") password: z
.regex(/[0-9]/, "Password must contain at least one digit"), .string()
look: z.string().optional(), .min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one digit"),
look: z.string().optional(),
}); });
// A valid starter Habbo figure so the avatar renders in-client immediately. // A valid starter Habbo figure so the avatar renders in-client immediately.
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
export async function register(prevState: string | null, formData: FormData): Promise<string | null> { export async function register(
const raw = { _prevState: string | null,
username: String(formData.get("username") ?? "") formData: FormData,
.normalize("NFC") ): Promise<string | null> {
.trim(), const raw = {
mail: String(formData.get("mail") ?? "") username: String(formData.get("username") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim(),
.toLowerCase(), mail: String(formData.get("mail") ?? "")
password: String(formData.get("password") ?? "").normalize("NFC"), .normalize("NFC")
look: .trim()
String(formData.get("look") ?? "") .toLowerCase(),
.normalize("NFC") password: String(formData.get("password") ?? "").normalize("NFC"),
.trim() || DEFAULT_LOOK, look:
}; String(formData.get("look") ?? "")
.normalize("NFC")
.trim() || DEFAULT_LOOK,
};
const parsed = registerSchema.safeParse(raw); const parsed = registerSchema.safeParse(raw);
if (!parsed.success) { if (!parsed.success) {
return parsed.error.errors[0]?.message ?? "Invalid input"; return parsed.error.errors[0]?.message ?? "Invalid input";
} }
const { username, mail, password, look } = parsed.data; const { username, mail, password, look } = parsed.data;
const hasEmail = !!mail; const hasEmail = !!mail;
const ip = await clientIp(); const ip = await clientIp();
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam. // Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) { if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
return "Too many sign-up attempts. Please wait a few minutes and try again."; return "Too many sign-up attempts. Please wait a few minutes and try again.";
} }
// CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings. // CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings.
const cfg = await captchaConfig(); const cfg = await captchaConfig();
if (cfg.provider !== "none") { if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC"); const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip))) return "Captcha verification failed. Please try again."; if (!(await verifyCaptcha(token, ip)))
} return "Captcha verification failed. Please try again.";
}
// VPN/proxy block (only when enabled in /admin/vpn). // VPN/proxy block (only when enabled in /admin/vpn).
if ((await checkVpn(ip)).blocked) { if ((await checkVpn(ip)).blocked) {
return ( return (
(await siteSettings.get("vpn_block_message", "")) || (await siteSettings.get("vpn_block_message", "")) ||
"Registrations from VPN/proxy connections are not allowed." "Registrations from VPN/proxy connections are not allowed."
); );
} }
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS. // Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0; const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
if (max > 0) { if (max > 0) {
const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0); const count = await prisma.user
if (count >= max) return "You have reached the maximum number of accounts for your connection."; .count({ where: { ipRegister: ip } })
} .catch(() => 0);
if (count >= max)
return "You have reached the maximum number of accounts for your connection.";
}
// Uniqueness check. // Uniqueness check.
try { try {
const existing = await prisma.user.findUnique({ const existing = await prisma.user.findUnique({
where: { username }, where: { username },
select: { id: true }, select: { id: true },
}); });
if (existing) return "That username is already taken"; if (existing) return "That username is already taken";
} catch { } catch {
return "Registration is temporarily unavailable"; return "Registration is temporarily unavailable";
} }
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
try { try {
await prisma.user.create({ await prisma.user.create({
data: { data: {
username, username,
password: await hashPassword(password), password: await hashPassword(password),
mail: hasEmail ? mail : null, mail: hasEmail ? mail : null,
accountCreated: now, accountCreated: now,
ipRegister: ip, ipRegister: ip,
ipCurrent: ip, ipCurrent: ip,
look, look,
}, },
select: { id: true }, select: { id: true },
}); });
if (hasEmail) { if (hasEmail) {
try { try {
await sendVerification(mail); await sendVerification(mail);
} catch { } catch {
// No-op: account is created; user can request a new link later. // No-op: account is created; user can request a new link later.
} }
} }
} catch { } catch {
return "Could not create the account (is the username unique?)"; return "Could not create the account (is the username unique?)";
} }
if (hasEmail) { if (hasEmail) {
redirect("/login?registered=1"); redirect("/login?registered=1");
} else { } else {
const { signIn } = await import("@/lib/auth"); const { signIn } = await import("@/lib/auth");
await signIn("credentials", { username, password, redirect: false }); await signIn("credentials", { username, password, redirect: false });
redirect("/verify?method=discord"); redirect("/verify?method=discord");
} }
} }
+92 -70
View File
@@ -7,88 +7,110 @@ import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateRoomItem(payload: Record<string, unknown>) { export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requireStaff(); const staff = await requireStaff();
const { roomId, itemId, ...data } = payload as { roomId: number; itemId: number; [key: string]: unknown }; const { roomId, itemId, ...data } = payload as {
await prisma.items.update({ where: { id: itemId }, data: data as any }); roomId: number;
await logStaffActivity({ itemId: number;
staffId: staff.id, [key: string]: unknown;
action: "room_item_update", };
description: `Updated item #${itemId} in room #${roomId}`, await prisma.items.update({ where: { id: itemId }, data: data as any });
targetType: "room_item", await logStaffActivity({
targetId: itemId, staffId: staff.id,
}); action: "room_item_update",
revalidatePath(`/admin/rooms/${roomId}/furni`); description: `Updated item #${itemId} in room #${roomId}`,
targetType: "room_item",
targetId: itemId,
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
} }
export async function bulkDeleteRoomItems({ roomId, itemIds }: { roomId: number; itemIds: number[] }) { export async function bulkDeleteRoomItems({
const staff = await requireStaff(); roomId,
await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } }); itemIds,
await logStaffActivity({ }: {
staffId: staff.id, roomId: number;
action: "room_items_bulk_delete", itemIds: number[];
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`, }) {
targetType: "room_item", const staff = await requireStaff();
}); await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } });
revalidatePath(`/admin/rooms/${roomId}/furni`); await logStaffActivity({
staffId: staff.id,
action: "room_items_bulk_delete",
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
targetType: "room_item",
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
} }
export async function deleteRoomItem({ roomId, itemId }: { roomId: number; itemId: number }) { export async function deleteRoomItem({
const staff = await requireStaff(); roomId,
await prisma.items.delete({ where: { id: itemId } }); itemId,
await logStaffActivity({ }: {
staffId: staff.id, roomId: number;
action: "room_item_delete", itemId: number;
description: `Deleted item #${itemId} from room #${roomId}`, }) {
targetType: "room_item", const staff = await requireStaff();
targetId: itemId, await prisma.items.delete({ where: { id: itemId } });
}); await logStaffActivity({
revalidatePath(`/admin/rooms/${roomId}/furni`); staffId: staff.id,
action: "room_item_delete",
description: `Deleted item #${itemId} from room #${roomId}`,
targetType: "room_item",
targetId: itemId,
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
} }
export async function roomRconAction({ roomId, action }: { roomId: number; action: string }) { export async function roomRconAction({
await requireStaff(); roomId,
if (action === "reload") { action,
await rcon.send("reloadroom", { room_id: roomId }); }: {
} else if (action === "kick") { roomId: number;
await rcon.send("kickall", { room_id: roomId }); action: string;
} else if (action === "lock") { }) {
await rcon.send("updateroom", { room_id: roomId, state: "locked" }); await requireStaff();
} else if (action === "unlock") { if (action === "reload") {
await rcon.send("updateroom", { room_id: roomId, state: "open" }); await rcon.send("reloadroom", { room_id: roomId });
} } else if (action === "kick") {
await rcon.send("kickall", { room_id: roomId });
} else if (action === "lock") {
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
} else if (action === "unlock") {
await rcon.send("updateroom", { room_id: roomId, state: "open" });
}
} }
export async function deleteRoom({ id }: { id: number }) { export async function deleteRoom({ id }: { id: number }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.rooms.delete({ where: { id } }); await prisma.rooms.delete({ where: { id } });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_delete", action: "room_delete",
description: `Deleted room #${id}`, description: `Deleted room #${id}`,
targetType: "room", targetType: "room",
targetId: id, targetId: id,
}); });
revalidatePath("/admin/rooms"); revalidatePath("/admin/rooms");
} }
export async function updateRoom({ export async function updateRoom({
id, id,
...data ...data
}: { }: {
id: number; id: number;
name?: string; name?: string;
description?: string; description?: string;
state?: string; state?: string;
usersMax?: number; usersMax?: number;
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
await prisma.rooms.update({ where: { id }, data: data as any }); await prisma.rooms.update({ where: { id }, data: data as any });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "room_update", action: "room_update",
description: `Updated room #${id}`, description: `Updated room #${id}`,
targetType: "room", targetType: "room",
targetId: id, targetId: id,
}); });
revalidatePath(`/admin/rooms/${id}`); revalidatePath(`/admin/rooms/${id}`);
} }
+108 -85
View File
@@ -1,108 +1,131 @@
"use server"; "use server";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { mkdir, writeFile, unlink } from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const FAVICON_DIR = "public/assets/images/media/favicon"; const FAVICON_DIR = "public/assets/images/media/favicon";
const MAX_SIZE = 2 * 1024 * 1024; // 2MB const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"]; const ALLOWED = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/x-icon",
"image/svg+xml",
];
export async function saveFavicon( export async function saveFavicon(
formData: FormData, formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> { ): Promise<{ success: boolean; url?: string; error?: string }> {
try { try {
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { success: false, error: "No file provided" }; if (!file || file.size === 0)
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" }; return { success: false, error: "No file provided" };
if (!ALLOWED.includes(file.type)) if (file.size > MAX_SIZE)
return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" }; return { success: false, error: "File too large (max 2MB)" };
if (!ALLOWED.includes(file.type))
return {
success: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
};
const mimeExt: Record<string, string> = { const mimeExt: Record<string, string> = {
"image/png": "png", "image/png": "png",
"image/jpeg": "jpg", "image/jpeg": "jpg",
"image/gif": "gif", "image/gif": "gif",
"image/webp": "webp", "image/webp": "webp",
"image/x-icon": "ico", "image/x-icon": "ico",
"image/svg+xml": "svg", "image/svg+xml": "svg",
}; };
const ext = mimeExt[file.type] ?? "png"; const ext = mimeExt[file.type] ?? "png";
const filename = `favicon-${Date.now()}.${ext}`; const filename = `favicon-${Date.now()}.${ext}`;
const baseDir = path.resolve(process.cwd(), FAVICON_DIR); const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
const filePath = path.resolve(baseDir, filename); const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) { if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" }; return { success: false, error: "Invalid path" };
} }
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true }); await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer); await writeFile(filePath, buffer);
const url = `/api/media/favicon/${filename}`; const url = `/api/media/favicon/${filename}`;
// Remove old favicon file if it exists // Remove old favicon file if it exists
const oldUrl = await siteSettings.get("cms_favicon"); const oldUrl = await siteSettings.get("cms_favicon");
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) { if (oldUrl?.startsWith("/api/media/favicon/")) {
const oldName = oldUrl.replace("/api/media/favicon/", ""); const oldName = oldUrl.replace("/api/media/favicon/", "");
if (!oldName.includes("..") && !oldName.includes("/")) { if (!oldName.includes("..") && !oldName.includes("/")) {
const oldPath = path.resolve(baseDir, oldName); const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) { if (oldPath.startsWith(baseDir + path.sep)) {
try { try {
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath); await unlink(oldPath);
} catch { } catch {
/* ignore if file doesn't exist */ /* ignore if file doesn't exist */
} }
} }
} }
} }
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
where: { key: "cms_favicon" }, where: { key: "cms_favicon" },
update: { value: url }, update: { value: url },
create: { key: "cms_favicon", value: url, comment: "Favicon URL" }, create: { key: "cms_favicon", value: url, comment: "Favicon URL" },
}); });
siteSettings.reload(); siteSettings.reload();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
revalidatePath("/admin/favicon"); revalidatePath("/admin/favicon");
return { success: true, url }; return { success: true, url };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
} success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
} }
export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> { export async function deleteFavicon(): Promise<{
try { success: boolean;
const oldUrl = await siteSettings.get("cms_favicon"); error?: string;
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) { }> {
const baseDir = path.resolve(process.cwd(), FAVICON_DIR); try {
const oldName = oldUrl.replace("/api/media/favicon/", ""); const oldUrl = await siteSettings.get("cms_favicon");
if (!oldName.includes("..") && !oldName.includes("/")) { if (oldUrl?.startsWith("/api/media/favicon/")) {
const oldPath = path.resolve(baseDir, oldName); const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
if (oldPath.startsWith(baseDir + path.sep)) { const oldName = oldUrl.replace("/api/media/favicon/", "");
try { if (!oldName.includes("..") && !oldName.includes("/")) {
// eslint-disable-next-line security/detect-non-literal-fs-filename const oldPath = path.resolve(baseDir, oldName);
await unlink(oldPath); if (oldPath.startsWith(baseDir + path.sep)) {
} catch { try {
/* ignore */ // eslint-disable-next-line security/detect-non-literal-fs-filename
} await unlink(oldPath);
} } catch {
} /* ignore */
} }
}
}
}
await prisma.websiteSetting.delete({ where: { key: "cms_favicon" } }).catch(() => {}); await prisma.websiteSetting
siteSettings.reload(); .delete({ where: { key: "cms_favicon" } })
revalidatePath("/", "layout"); .catch(() => {});
revalidatePath("/admin/favicon"); siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath("/admin/favicon");
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
} success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
} }
+84 -77
View File
@@ -1,105 +1,112 @@
"use server"; "use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { mkdir, writeFile } from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
const MEDIA_DIR = "public/assets/images/media/logo"; const MEDIA_DIR = "public/assets/images/media/logo";
export async function saveLogo( export async function saveLogo(
formData: FormData, formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> { ): Promise<{ success: boolean; url?: string; error?: string }> {
try { try {
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file) return { success: false, error: "No file provided" }; if (!file) return { success: false, error: "No file provided" };
const ext = const ext =
file.type === "image/png" file.type === "image/png"
? "png" ? "png"
: file.type === "image/gif" : file.type === "image/gif"
? "gif" ? "gif"
: file.type === "image/jpeg" : file.type === "image/jpeg"
? "jpg" ? "jpg"
: file.type === "image/webp" : file.type === "image/webp"
? "webp" ? "webp"
: "png"; : "png";
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = path.resolve(process.cwd(), MEDIA_DIR); const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
const filePath = path.resolve(baseDir, filename); const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) { if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" }; return { success: false, error: "Invalid path" };
} }
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true }); await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer); await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`; const url = `/api/media/logo/${filename}`;
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
where: { key: "cms_logo" }, where: { key: "cms_logo" },
update: { value: url }, update: { value: url },
create: { key: "cms_logo", value: url, comment: "Logo (generator)" }, create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
}); });
siteSettings.reload(); siteSettings.reload();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
return { success: true, url }; return { success: true, url };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
} success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
} }
export async function saveLogoFromUrl( export async function saveLogoFromUrl(
gifUrl: string, gifUrl: string,
): Promise<{ success: boolean; url?: string; error?: string }> { ): Promise<{ success: boolean; url?: string; error?: string }> {
try { try {
const res = await fetch(gifUrl); const res = await fetch(gifUrl);
if (!res.ok) return { success: false, error: `Failed to fetch GIF: ${res.status}` }; if (!res.ok)
return { success: false, error: `Failed to fetch GIF: ${res.status}` };
const contentType = res.headers.get("content-type") ?? "image/gif"; const contentType = res.headers.get("content-type") ?? "image/gif";
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
const ext = const ext =
contentType === "image/png" contentType === "image/png"
? "png" ? "png"
: contentType === "image/gif" : contentType === "image/gif"
? "gif" ? "gif"
: contentType === "image/jpeg" : contentType === "image/jpeg"
? "jpg" ? "jpg"
: contentType === "image/webp" : contentType === "image/webp"
? "webp" ? "webp"
: "gif"; : "gif";
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = path.resolve(process.cwd(), MEDIA_DIR); const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
const filePath = path.resolve(baseDir, filename); const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) { if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" }; return { success: false, error: "Invalid path" };
} }
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true }); await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename // eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer); await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`; const url = `/api/media/logo/${filename}`;
await prisma.websiteSetting.upsert({ await prisma.websiteSetting.upsert({
where: { key: "cms_logo" }, where: { key: "cms_logo" },
update: { value: url }, update: { value: url },
create: { key: "cms_logo", value: url, comment: "Logo (generator)" }, create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
}); });
siteSettings.reload(); siteSettings.reload();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
return { success: true, url }; return { success: true, url };
} catch (e) { } catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; return {
} success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
} }
+92 -92
View File
@@ -21,50 +21,50 @@ const MESSAGE_MAX = 10000;
* target). The emulator surfaces the pending request in the in-game messenger. * target). The emulator surfaces the pending request in the in-game messenger.
*/ */
export async function sendFriendRequest(formData: FormData): Promise<void> { export async function sendFriendRequest(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
const fromId = Number(session?.user?.id); const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) return; if (!Number.isInteger(fromId) || fromId <= 0) return;
const toId = Number(formData.get("userId")); const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) return; if (!Number.isInteger(toId) || toId <= 0) return;
// Can't befriend yourself. // Can't befriend yourself.
if (toId === fromId) return; if (toId === fromId) return;
try { try {
// Guard against duplicate pending requests and already-existing friendships. // Guard against duplicate pending requests and already-existing friendships.
const [existingRequest, existingFriendship] = await Promise.all([ const [existingRequest, existingFriendship] = await Promise.all([
prisma.messengerFriendrequests.findFirst({ prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId }, where: { userFromId: fromId, userToId: toId },
select: { id: true }, select: { id: true },
}), }),
prisma.messengerFriendships.findFirst({ prisma.messengerFriendships.findFirst({
where: { where: {
OR: [ OR: [
{ userOneId: fromId, userTwoId: toId }, { userOneId: fromId, userTwoId: toId },
{ userOneId: toId, userTwoId: fromId }, { userOneId: toId, userTwoId: fromId },
], ],
}, },
select: { id: true }, select: { id: true },
}), }),
]); ]);
if (existingRequest || existingFriendship) return; if (existingRequest || existingFriendship) return;
await prisma.messengerFriendrequests.create({ await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId }, data: { userFromId: fromId, userToId: toId },
}); });
} catch { } catch {
// DB unavailable — fail soft; nothing to persist. // DB unavailable — fail soft; nothing to persist.
return; return;
} }
// Optional: revalidate the target profile if a username was supplied, purely // Optional: revalidate the target profile if a username was supplied, purely
// to refresh any request-state UI rendered there. // to refresh any request-state UI rendered there.
const username = String(formData.get("username") ?? "") const username = String(formData.get("username") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (username) revalidatePath(`/u/${username}`); if (username) revalidatePath(`/u/${username}`);
} }
/** /**
@@ -80,65 +80,65 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
* stamp posts_count = 1 to match the emulator's bookkeeping. * stamp posts_count = 1 to match the emulator's bookkeeping.
*/ */
export async function postThread(formData: FormData): Promise<void> { export async function postThread(formData: FormData): Promise<void> {
const session = await auth(); const session = await auth();
const openerId = Number(session?.user?.id); const openerId = Number(session?.user?.id);
if (!Number.isInteger(openerId) || openerId <= 0) return; if (!Number.isInteger(openerId) || openerId <= 0) return;
const guildId = Number(formData.get("guildId")); const guildId = Number(formData.get("guildId"));
if (!Number.isInteger(guildId) || guildId <= 0) return; if (!Number.isInteger(guildId) || guildId <= 0) return;
const subject = String(formData.get("subject") ?? "") const subject = String(formData.get("subject") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, SUBJECT_MAX); .slice(0, SUBJECT_MAX);
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 (!subject || !message) return; if (!subject || !message) return;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
try { try {
// Confirm the guild exists (and has a forum) before opening a thread. // Confirm the guild exists (and has a forum) before opening a thread.
const guild = await prisma.guilds.findUnique({ const guild = await prisma.guilds.findUnique({
where: { id: guildId }, where: { id: guildId },
select: { id: true }, select: { id: true },
}); });
if (!guild) return; if (!guild) return;
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({ const thread = await tx.guildsForumsThreads.create({
data: { data: {
guildId, guildId,
openerId, openerId,
subject, subject,
postsCount: 1, postsCount: 1,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
state: 0, state: 0,
pinned: 0, pinned: 0,
locked: 0, locked: 0,
adminId: 0, adminId: 0,
}, },
select: { id: true }, select: { id: true },
}); });
await tx.guildsForumsComments.create({ await tx.guildsForumsComments.create({
data: { data: {
threadId: thread.id, threadId: thread.id,
userId: openerId, userId: openerId,
message, message,
createdAt: now, createdAt: now,
state: 0, state: 0,
adminId: 0, adminId: 0,
}, },
}); });
}); });
} catch { } catch {
// DB unavailable — fail soft; nothing to persist. // DB unavailable — fail soft; nothing to persist.
return; return;
} }
revalidatePath(`/guilds/${guildId}/forum`); revalidatePath(`/guilds/${guildId}/forum`);
} }
+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
@@ -7,223 +7,232 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { notify } from "@/lib/services/webhook"; import { notify } from "@/lib/services/webhook";
import { import {
assignTicketSchema, assignTicketSchema,
createTicketSchema, createTicketSchema,
replyTicketSchema, replyTicketSchema,
updateTicketPrioritySchema, updateTicketPrioritySchema,
updateTicketStatusSchema, updateTicketStatusSchema,
} from "@/lib/validators/ticket"; } from "@/lib/validators/ticket";
// ── User actions (authenticated, no admin perms needed) ────────────── // ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction({ schema: createTicketSchema }, async (ctx) => { export const createTicket = authAction(
const ticket = await prisma.websiteTicket.create({ { schema: createTicketSchema },
data: { async (ctx) => {
subject: ctx.data.subject, const ticket = await prisma.websiteTicket.create({
category: ctx.data.category, data: {
creatorId: ctx.session.user.id, subject: ctx.data.subject,
}, category: ctx.data.category,
}); creatorId: ctx.session.user.id,
},
});
// Create the first message // Create the first message
await prisma.websiteTicketMessage.create({ await prisma.websiteTicketMessage.create({
data: { data: {
ticketId: ticket.id, ticketId: ticket.id,
userId: ctx.session.user.id, userId: ctx.session.user.id,
message: ctx.data.message, message: ctx.data.message,
isStaff: 0, isStaff: 0,
}, },
}); });
notify({ notify({
action: "ticket_create", action: "ticket_create",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: `#${ticket.id} - ${ticket.subject}`, target: `#${ticket.id} - ${ticket.subject}`,
details: `Category: ${ticket.category}`, details: `Category: ${ticket.category}`,
}); });
return actionOk({ id: ticket.id }); return actionOk({ id: ticket.id });
}); },
);
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => { export const userReplyTicket = authAction(
const ticket = await prisma.websiteTicket.findUnique({ { schema: replyTicketSchema },
where: { id: ctx.data.ticketId }, async (ctx) => {
}); const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
});
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized"); if (ticket.creatorId !== ctx.session.user.id)
if (ticket.status === "closed") throw new ActionError("Ticket is closed"); throw new ActionError("Unauthorized");
if (ticket.status === "closed") throw new ActionError("Ticket is closed");
await prisma.websiteTicketMessage.create({ await prisma.websiteTicketMessage.create({
data: { data: {
ticketId: ctx.data.ticketId, ticketId: ctx.data.ticketId,
userId: ctx.session.user.id, userId: ctx.session.user.id,
message: ctx.data.message, message: ctx.data.message,
isStaff: 0, isStaff: 0,
}, },
}); });
// If ticket was in "waiting" (waiting for user), move back to open // If ticket was in "waiting" (waiting for user), move back to open
if (ticket.status === "waiting") { if (ticket.status === "waiting") {
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { status: "open" }, data: { status: "open" },
}); });
} }
return actionOk(); return actionOk();
}); },
);
export const closeTicketByUser = authAction( export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) }, { schema: replyTicketSchema.pick({ ticketId: true }) },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}); });
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized"); if (ticket.creatorId !== ctx.session.user.id)
if (ticket.status === "closed") throw new ActionError("Ticket is already closed"); throw new ActionError("Unauthorized");
if (ticket.status === "closed")
throw new ActionError("Ticket is already closed");
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { status: "closed", closedAt: new Date() }, data: { status: "closed", closedAt: new Date() },
}); });
return actionOk(); return actionOk();
}, },
); );
// ── Admin actions ──────────────────────────────────────────────────── // ── Admin actions ────────────────────────────────────────────────────
export const adminReplyTicket = adminAction( export const adminReplyTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema }, { permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}); });
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicketMessage.create({ await prisma.websiteTicketMessage.create({
data: { data: {
ticketId: ctx.data.ticketId, ticketId: ctx.data.ticketId,
userId: ctx.session.user.id, userId: ctx.session.user.id,
message: ctx.data.message, message: ctx.data.message,
isStaff: 1, isStaff: 1,
}, },
}); });
// Auto-assign if not assigned yet // Auto-assign if not assigned yet
const updates: Record<string, unknown> = { status: "waiting" }; const updates: Record<string, unknown> = { status: "waiting" };
if (!ticket.assigneeId) { if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id; updates.assigneeId = ctx.session.user.id;
} }
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: updates, data: updates,
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "ticket_reply", action: "ticket_reply",
target: "WebsiteTicket", target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
}); });
return actionOk(); return actionOk();
}, },
); );
export const updateTicketStatus = adminAction( export const updateTicketStatus = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema }, { permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}); });
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
const data: Record<string, unknown> = { status: ctx.data.status }; const data: Record<string, unknown> = { status: ctx.data.status };
if (ctx.data.status === "closed") { if (ctx.data.status === "closed") {
data.closedAt = new Date(); data.closedAt = new Date();
} }
if (ctx.data.status === "in_progress" && !ticket.assigneeId) { if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id; data.assigneeId = ctx.session.user.id;
} }
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data, data,
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "ticket_status_change", action: "ticket_status_change",
target: "WebsiteTicket", target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { status: ticket.status }, before: { status: ticket.status },
after: { status: ctx.data.status }, after: { status: ctx.data.status },
}); });
return actionOk(); return actionOk();
}, },
); );
export const assignTicket = adminAction( export const assignTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema }, { permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}); });
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { data: {
assigneeId: ctx.data.assigneeId, assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? "in_progress" : "open", status: ctx.data.assigneeId ? "in_progress" : "open",
}, },
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "ticket_assign", action: "ticket_assign",
target: "WebsiteTicket", target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId }, before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId }, after: { assigneeId: ctx.data.assigneeId },
}); });
return actionOk(); return actionOk();
}, },
); );
export const updateTicketPriority = adminAction( export const updateTicketPriority = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema }, { permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
async (ctx) => { async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({ const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
}); });
if (!ticket) throw new ActionError("Ticket not found"); if (!ticket) throw new ActionError("Ticket not found");
await prisma.websiteTicket.update({ await prisma.websiteTicket.update({
where: { id: ctx.data.ticketId }, where: { id: ctx.data.ticketId },
data: { priority: ctx.data.priority }, data: { priority: ctx.data.priority },
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "ticket_priority_change", action: "ticket_priority_change",
target: "WebsiteTicket", target: "WebsiteTicket",
targetId: ctx.data.ticketId, targetId: ctx.data.ticketId,
before: { priority: ticket.priority }, before: { priority: ticket.priority },
after: { priority: ctx.data.priority }, after: { priority: ctx.data.priority },
}); });
return actionOk(); return actionOk();
}, },
); );
+100 -70
View File
@@ -4,93 +4,123 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import JSON5 from "json5"; import JSON5 from "json5";
import { z } from "zod"; import { z } from "zod";
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files"; import {
CLIENT_TRANSLATION_FILES,
getClientTranslationFile,
} from "@/lib/client-translation-files";
import { patchJson5 } from "@/lib/json5-patch"; import { patchJson5 } from "@/lib/json5-patch";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({ const saveTranslationsSchema = z.object({
locale: z.enum([ locale: z.enum([
"en", "en",
"it", "it",
"nl", "nl",
"de", "de",
"fr", "fr",
"es", "es",
"pt", "pt",
"pl", "pl",
"sv", "sv",
"tr", "tr",
"ro", "ro",
"hu", "hu",
"cs", "cs",
"sk", "sk",
"da", "da",
"no", "no",
"el", "el",
"bg", "bg",
"hr", "hr",
"sr", "sr",
"uk", "uk",
"ru", "ru",
]), ]),
data: z.record(z.string(), z.unknown()), data: z.record(z.string(), z.unknown()),
}); });
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => { export const saveTranslations = adminAction(
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden"); { schema: saveTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`); const filePath = path.join(
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8"); process.cwd(),
"messages",
`${ctx.data.locale}.json`,
);
await fs.writeFile(
filePath,
JSON.stringify(ctx.data.data, null, 2),
"utf-8",
);
return actionOk(); return actionOk();
}); },
);
const saveClientTranslationsSchema = z.object({ const saveClientTranslationsSchema = z.object({
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]), fileId: z.enum(
data: z.record(z.string(), z.string()), CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]],
),
data: z.record(z.string(), z.string()),
}); });
export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => { export const saveClientTranslations = adminAction(
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden"); { schema: saveClientTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const file = getClientTranslationFile(ctx.data.fileId); const file = getClientTranslationFile(ctx.data.fileId);
if (!file) throw new ActionError("Unknown file"); if (!file) throw new ActionError("Unknown file");
if (file.readOnly) throw new ActionError("File is read-only"); if (file.readOnly) throw new ActionError("File is read-only");
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but // file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it // Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list. // pulls the entire project into the NFT list.
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath); const absPath = path.join(
const raw = await fs.readFile(absPath, "utf-8"); /*turbopackIgnore: true*/ process.cwd(),
file.relPath,
);
const raw = await fs.readFile(absPath, "utf-8");
if (file.format === "json") { if (file.format === "json") {
// Plain JSON — no comments to preserve, just round-trip. // Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8"); await fs.writeFile(
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] }); absPath,
} JSON.stringify(ctx.data.data, null, 4),
"utf-8",
);
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
}
// JSON5: surgical line-level patch keeps headers and section comments // JSON5: surgical line-level patch keeps headers and section comments
// intact. Falls back to a full re-serialization (which DOES drop comments) // intact. Falls back to a full re-serialization (which DOES drop comments)
// only when an edited key cannot be located via the patch contract. // only when an edited key cannot be located via the patch contract.
const original: Record<string, string> = {}; const original: Record<string, string> = {};
const parsed = JSON5.parse(raw); const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) { for (const [k, v] of Object.entries(parsed)) {
original[k] = v == null ? "" : String(v); original[k] = v == null ? "" : String(v);
} }
} }
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data); const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
if (unpatchedKeys.length === 0) { if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, "utf-8"); await fs.writeFile(absPath, content, "utf-8");
return actionOk({ commentsLost: false, unpatchedKeys }); return actionOk({ commentsLost: false, unpatchedKeys });
} }
// At least one key could not be patched surgically (e.g. unusual // At least one key could not be patched surgically (e.g. unusual
// formatting or a brand-new key). Fall back to a full re-serialization // formatting or a brand-new key). Fall back to a full re-serialization
// and warn the caller that comments were lost. // and warn the caller that comments were lost.
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), "utf-8"); await fs.writeFile(
return actionOk({ commentsLost: true, unpatchedKeys }); absPath,
}); JSON5.stringify(ctx.data.data, null, 4),
"utf-8",
);
return actionOk({ commentsLost: true, unpatchedKeys });
},
);
+94 -87
View File
@@ -1,128 +1,135 @@
"use server"; "use server";
import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { randomBytes } from "node:crypto"; import { env } from "@/env";
import { auth } from "@/lib/auth";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp"; import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { env } from "@/env";
async function sessionUserId(): Promise<number> { async function sessionUserId(): Promise<number> {
const session = await auth(); const session = await auth();
if (!session?.user?.id) redirect("/login"); if (!session?.user?.id) redirect("/login");
return Number(session.user.id); return Number(session.user.id);
} }
function generateRecoveryCodes(): string[] { function generateRecoveryCodes(): string[] {
const codes: string[] = []; const codes: string[] = [];
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
codes.push( codes.push(
randomBytes(4) randomBytes(4)
.toString("hex") .toString("hex")
.toUpperCase() .toUpperCase()
.replace(/(.{4})/, "$1-"), .replace(/(.{4})/, "$1-"),
); );
} }
return codes; return codes;
} }
/** Verify a TOTP code OR a recovery code. Returns the updated recovery codes (minus used one) if a recovery code was used, or null on failure. */ /** Verify a TOTP code OR a recovery code. Returns the updated recovery codes (minus used one) if a recovery code was used, or null on failure. */
async function verifyTwoFactorCode( async function verifyTwoFactorCode(
userId: number, userId: number,
code: string, code: string,
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> { ): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true }, select: { twoFactorSecret: true, twoFactorRecoveryCodes: true },
}); });
if (!user?.twoFactorSecret) return { ok: false }; if (!user?.twoFactorSecret) return { ok: false };
// Try TOTP first // Try TOTP first
try { try {
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret); const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(
if (verifyTotp(code, secret)) return { ok: true }; user.twoFactorSecret,
} catch { );
/* fall through to recovery */ if (verifyTotp(code, secret)) return { ok: true };
} } catch {
/* fall through to recovery */
}
// Try recovery codes // Try recovery codes
if (user.twoFactorRecoveryCodes) { if (user.twoFactorRecoveryCodes) {
let codes: string[]; let codes: string[];
try { try {
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
} catch { } catch {
codes = []; codes = [];
} }
const idx = codes.indexOf(code); const idx = codes.indexOf(code);
if (idx !== -1) { if (idx !== -1) {
codes.splice(idx, 1); codes.splice(idx, 1);
const remaining = codes.length > 0 ? JSON.stringify(codes) : null; const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
return { ok: true, updatedRecoveryCodes: remaining }; return { ok: true, updatedRecoveryCodes: remaining };
} }
} }
return { ok: false }; return { ok: false };
} }
/** Step 1: generate a secret and recovery codes, store encrypted but UNconfirmed. */ /** Step 1: generate a secret and recovery codes, store encrypted but UNconfirmed. */
export async function beginTwoFactor(): Promise<void> { export async function beginTwoFactor(): Promise<void> {
const id = await sessionUserId(); const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
const secret = generateTotpSecret(); const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret); const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
const codes = generateRecoveryCodes(); const codes = generateRecoveryCodes();
await prisma.user.update({ await prisma.user.update({
where: { id }, where: { id },
data: { data: {
twoFactorSecret: encrypted, twoFactorSecret: encrypted,
twoFactorConfirmedAt: null, twoFactorConfirmedAt: null,
twoFactorRecoveryCodes: JSON.stringify(codes), twoFactorRecoveryCodes: JSON.stringify(codes),
}, },
}); });
revalidatePath("/settings/2fa"); revalidatePath("/settings/2fa");
} }
/** Step 2: verify a code against the pending secret, then confirm and show recovery codes. */ /** Step 2: verify a code against the pending secret, then confirm and show recovery codes. */
export async function confirmTwoFactor(formData: FormData): Promise<void> { export async function confirmTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId(); const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit"); if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok)
redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const { ok } = await verifyTwoFactorCode(id, code); const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode"); if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } }); await prisma.user.update({
redirect("/settings/2fa?enabled=1"); where: { id },
data: { twoFactorConfirmedAt: new Date() },
});
redirect("/settings/2fa?enabled=1");
} }
export async function disableTwoFactor(formData: FormData): Promise<void> { export async function disableTwoFactor(formData: FormData): Promise<void> {
const id = await sessionUserId(); const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey"); if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit"); if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok)
redirect("/settings/2fa?error=ratelimit");
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
const { ok } = await verifyTwoFactorCode(id, code); const { ok } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode"); if (!ok) redirect("/settings/2fa?error=badcode");
await prisma.user.update({ await prisma.user.update({
where: { id }, where: { id },
data: { data: {
twoFactorSecret: null, twoFactorSecret: null,
twoFactorRecoveryCodes: null, twoFactorRecoveryCodes: null,
twoFactorConfirmedAt: null, twoFactorConfirmedAt: null,
}, },
}); });
redirect("/settings/2fa?disabled=1"); redirect("/settings/2fa?disabled=1");
} }
+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
@@ -11,404 +11,447 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { notify } from "@/lib/services/webhook"; import { notify } from "@/lib/services/webhook";
import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema } from "@/lib/validators/user"; import {
banUserSchema,
createUserSchema,
giveBadgeSchema,
updateUserSchema,
} from "@/lib/validators/user";
const DEFAULT_LOOK = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64"; const DEFAULT_LOOK =
"hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
export const createUser = adminAction( export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema }, { permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => { async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data; const { username, mail, password, rank, motto } = ctx.data;
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) { if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
throw new ActionError("Cannot assign rank equal or higher than your own"); throw new ActionError("Cannot assign rank equal or higher than your own");
} }
const hashedPassword = await hash(password, 12); const hashedPassword = await hash(password, 12);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
try { try {
const user = await prisma.$transaction(async (tx) => { const user = await prisma.$transaction(async (tx) => {
const created = await tx.user.create({ const created = await tx.user.create({
data: { data: {
username, username,
mail, mail,
password: hashedPassword, password: hashedPassword,
rank, rank,
motto: motto || "I'm new here!", motto: motto || "I'm new here!",
look: DEFAULT_LOOK, look: DEFAULT_LOOK,
credits: 5000, credits: 5000,
pixels: 5000, pixels: 5000,
accountCreated: now, accountCreated: now,
ipRegister: "0.0.0.0", ipRegister: "0.0.0.0",
ipCurrent: "0.0.0.0", ipCurrent: "0.0.0.0",
}, },
}); });
await tx.usersSettings.create({ data: { userId: created.id } }); await tx.usersSettings.create({ data: { userId: created.id } });
await tx.usersCurrency.createMany({ await tx.usersCurrency.createMany({
data: [ data: [
{ userId: created.id, type: 0, amount: 5000 }, { userId: created.id, type: 0, amount: 5000 },
{ userId: created.id, type: 5, amount: 5000 }, { userId: created.id, type: 5, amount: 5000 },
], ],
}); });
return created; return created;
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "user_create", action: "user_create",
target: "User", target: "User",
targetId: user.id, targetId: user.id,
after: { username, mail, rank }, after: { username, mail, rank },
}); });
notify({ notify({
action: "user_edit", action: "user_edit",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: username, target: username,
targetId: user.id, targetId: user.id,
details: "Account created by admin", details: "Account created by admin",
}); });
return actionOk({ id: user.id, username: user.username }); return actionOk({ id: user.id, username: user.username });
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { if (
const target = (err.meta?.target as string[]) ?? []; err instanceof Prisma.PrismaClientKnownRequestError &&
if (target.includes("username")) throw new ActionError("Username already taken"); err.code === "P2002"
if (target.includes("mail")) throw new ActionError("Email already registered"); ) {
throw new ActionError("Username or email already in use"); const target = (err.meta?.target as string[]) ?? [];
} if (target.includes("username"))
throw err; throw new ActionError("Username already taken");
} if (target.includes("mail"))
}, throw new ActionError("Email already registered");
throw new ActionError("Username or email already in use");
}
throw err;
}
},
); );
const updateUserInput = updateUserSchema.extend({ const updateUserInput = updateUserSchema.extend({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
}); });
export const updateUser = adminAction( export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput }, { permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => { async (ctx) => {
const { id, diamonds, duckets, ...userData } = ctx.data; const { id, diamonds, duckets, ...userData } = ctx.data;
const targetUser = await guardRank(id, ctx.session.user.rank); const targetUser = await guardRank(id, ctx.session.user.rank);
if (userData.rank !== undefined && userData.rank >= ctx.session.user.rank && ctx.session.user.rank < 7) { if (
throw new ActionError("Cannot assign rank equal or higher than your own"); userData.rank !== undefined &&
} userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
await prisma.user.update({ where: { id }, data: userData }); await prisma.user.update({ where: { id }, data: userData });
if (diamonds !== undefined) { if (diamonds !== undefined) {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 5 } }, where: { userId_type: { userId: id, type: 5 } },
update: { amount: diamonds }, update: { amount: diamonds },
create: { userId: id, type: 5, amount: diamonds }, create: { userId: id, type: 5, amount: diamonds },
}); });
} }
if (duckets !== undefined) { if (duckets !== undefined) {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
where: { userId_type: { userId: id, type: 0 } }, where: { userId_type: { userId: id, type: 0 } },
update: { amount: duckets }, update: { amount: duckets },
create: { userId: id, type: 0, amount: duckets }, create: { userId: id, type: 0, amount: duckets },
}); });
} }
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "user_edit", action: "user_edit",
target: "User", target: "User",
targetId: id, targetId: id,
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank }, before: {
after: userData, username: targetUser.username,
}); mail: targetUser.mail,
rank: targetUser.rank,
},
after: userData,
});
notify({ notify({
action: "user_edit", action: "user_edit",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: targetUser.username, target: targetUser.username,
targetId: id, targetId: id,
}); });
return actionOk(); return actionOk();
}, },
); );
const banInput = banUserSchema.extend({}); const banInput = banUserSchema.extend({});
export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInput }, async (ctx) => { export const banUser = adminAction(
const { userId, reason, duration, type, ip } = ctx.data; { permission: PERMS.USERS_BAN, schema: banInput },
async (ctx) => {
const { userId, reason, duration, type, ip } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank); const targetUser = await guardRank(userId, ctx.session.user.rank);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const banExpire = duration > 0 ? now + duration * 3600 : 0; const banExpire = duration > 0 ? now + duration * 3600 : 0;
await prisma.ban.create({ await prisma.ban.create({
data: { data: {
userId, userId,
userStaffId: ctx.session.user.id, userStaffId: ctx.session.user.id,
timestamp: now, timestamp: now,
banExpire, banExpire,
banReason: reason, banReason: reason,
type: type || "account", type: type || "account",
ip: ip || "", ip: ip || "",
machineId: "", machineId: "",
}, },
}); });
await rcon.disconnectUser(userId); await rcon.disconnectUser(userId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "ban", action: "ban",
target: "User", target: "User",
targetId: userId, targetId: userId,
after: { reason, type, duration }, after: { reason, type, duration },
}); });
notify({ notify({
action: "ban", action: "ban",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: targetUser.username, target: targetUser.username,
details: reason, details: reason,
}); });
return actionOk(); return actionOk();
}); },
);
const unbanInput = z.object({ userId: z.coerce.number().int().positive() }); const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unbanInput }, async (ctx) => { export const unbanUser = adminAction(
const { userId } = ctx.data; { permission: PERMS.USERS_BAN, schema: unbanInput },
async (ctx) => {
const { userId } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank); const targetUser = await guardRank(userId, ctx.session.user.rank);
await prisma.ban.deleteMany({ where: { userId } }); await prisma.ban.deleteMany({ where: { userId } });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "unban", action: "unban",
target: "User", target: "User",
targetId: userId, targetId: userId,
}); });
notify({ notify({
action: "unban", action: "unban",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: targetUser.username, target: targetUser.username,
}); });
return actionOk(); return actionOk();
}); },
);
export const giveBadge = adminAction( export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema }, { permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
async (ctx) => { async (ctx) => {
const { userId, badgeCode } = ctx.data; const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank); await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } }); const existing = await prisma.usersBadges.findFirst({
if (existing) throw new ActionError("Badge already assigned"); where: { userId, badgeCode },
});
if (existing) throw new ActionError("Badge already assigned");
await prisma.usersBadges.create({ data: { userId, badgeCode } }); await prisma.usersBadges.create({ data: { userId, badgeCode } });
await rcon.giveBadge(userId, badgeCode); await rcon.giveBadge(userId, badgeCode);
return actionOk(); return actionOk();
}, },
); );
// ── Remove Badge ──────────────────────────────────────────────────── // ── Remove Badge ────────────────────────────────────────────────────
const removeBadgeSchema = z.object({ const removeBadgeSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
badgeCode: z.string().min(1), badgeCode: z.string().min(1),
}); });
export const removeBadge = adminAction( export const removeBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema }, { permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
async (ctx) => { async (ctx) => {
const { userId, badgeCode } = ctx.data; const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank); await guardRank(userId, ctx.session.user.rank);
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } }); const existing = await prisma.usersBadges.findFirst({
if (!existing) throw new ActionError("Badge not found"); where: { userId, badgeCode },
});
if (!existing) throw new ActionError("Badge not found");
await prisma.usersBadges.delete({ where: { id: existing.id } }); await prisma.usersBadges.delete({ where: { id: existing.id } });
await rcon.removeBadge(userId, badgeCode); await rcon.removeBadge(userId, badgeCode);
return actionOk(); return actionOk();
}, },
); );
// ── Rank guard helper ─────────────────────────────────────────────── // ── Rank guard helper ───────────────────────────────────────────────
async function guardRank(targetUserId: number, sessionRank: number) { async function guardRank(targetUserId: number, sessionRank: number) {
const target = await prisma.user.findUnique({ const target = await prisma.user.findUnique({
where: { id: targetUserId }, where: { id: targetUserId },
select: { username: true, rank: true, mail: true }, select: { username: true, rank: true, mail: true },
}); });
if (!target) throw new ActionError("User not found"); if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) { if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank"); throw new ActionError("Cannot modify user with equal or higher rank");
} }
return target; return target;
} }
// ── Reset Password ────────────────────────────────────────────────── // ── Reset Password ──────────────────────────────────────────────────
const resetPasswordSchema = z.object({ const resetPasswordSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
}); });
export const resetPassword = adminAction( export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema }, { permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
async (ctx) => { async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank); const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const newPassword = crypto.randomBytes(12).toString("base64url").slice(0, 16); const newPassword = crypto
const hashed = await hash(newPassword, 10); .randomBytes(12)
.toString("base64url")
.slice(0, 16);
const hashed = await hash(newPassword, 10);
await prisma.user.update({ await prisma.user.update({
where: { id: ctx.data.userId }, where: { id: ctx.data.userId },
data: { password: hashed }, data: { password: hashed },
}); });
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "reset_password", action: "reset_password",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}); });
notify({ notify({
action: "user_edit", action: "user_edit",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: target.username, target: target.username,
details: "Password reset", details: "Password reset",
}); });
return actionOk({ newPassword }); return actionOk({ newPassword });
}, },
); );
// ── Disconnect User ───────────────────────────────────────────────── // ── Disconnect User ─────────────────────────────────────────────────
const disconnectSchema = z.object({ const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
}); });
export const disconnectUser = adminAction( export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema }, { permission: PERMS.USERS_EDIT, schema: disconnectSchema },
async (ctx) => { async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank); const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.disconnectUser(ctx.data.userId); const success = await rcon.disconnectUser(ctx.data.userId);
if (!success) throw new ActionError("Failed to disconnect. Is the emulator running?"); if (!success)
throw new ActionError("Failed to disconnect. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "user_disconnect", action: "user_disconnect",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}); });
notify({ notify({
action: "disconnect", action: "disconnect",
actor: ctx.session.user.username, actor: ctx.session.user.username,
target: target.username, target: target.username,
}); });
return actionOk(); return actionOk();
}, },
); );
// ── Alert User (in-game message) ──────────────────────────────────── // ── Alert User (in-game message) ────────────────────────────────────
const alertUserSchema = z.object({ const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500), message: z.string().min(1).max(500),
}); });
export const alertUser = adminAction( export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema }, { permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => { async (ctx) => {
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message); const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success) throw new ActionError("Failed to send alert. Is the emulator running?"); if (!success)
return actionOk(); throw new ActionError("Failed to send alert. Is the emulator running?");
}, return actionOk();
},
); );
// ── Mute User ─────────────────────────────────────────────────────── // ── Mute User ───────────────────────────────────────────────────────
const muteSchema = z.object({ const muteSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).default(0), duration: z.coerce.number().int().min(0).default(0),
}); });
export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => { export const muteUser = adminAction(
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); { permission: PERMS.USERS_EDIT, schema: muteSchema },
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration); async (ctx) => {
if (!success) throw new ActionError("Failed to mute. Is the emulator running?"); const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
if (!success)
throw new ActionError("Failed to mute. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "user_mute", action: "user_mute",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { duration: ctx.data.duration }, after: { duration: ctx.data.duration },
}); });
return actionOk(); return actionOk();
}); },
);
// ── Unmute User ───────────────────────────────────────────────────── // ── Unmute User ─────────────────────────────────────────────────────
const unmuteSchema = z.object({ const unmuteSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
}); });
export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, async (ctx) => { export const unmuteUser = adminAction(
await guardRank(ctx.data.userId, ctx.session.user.rank); { permission: PERMS.USERS_EDIT, schema: unmuteSchema },
const success = await rcon.unmuteUser(ctx.data.userId); async (ctx) => {
if (!success) throw new ActionError("Failed to unmute. Is the emulator running?"); await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.unmuteUser(ctx.data.userId);
if (!success)
throw new ActionError("Failed to unmute. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "user_unmute", action: "user_unmute",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
}); });
return actionOk(); return actionOk();
}); },
);
// ── Send Credits via RCON ─────────────────────────────────────────── // ── Send Credits via RCON ───────────────────────────────────────────
const sendCreditsSchema = z.object({ const sendCreditsSchema = z.object({
userId: z.coerce.number().int().positive(), userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().min(1).max(1000000), amount: z.coerce.number().int().min(1).max(1000000),
}); });
export const sendCredits = adminAction( export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema }, { permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => { async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount); const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
if (!success) throw new ActionError("Failed to send credits. Is the emulator running?"); if (!success)
throw new ActionError("Failed to send credits. Is the emulator running?");
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "user_send_credits", action: "user_send_credits",
target: "User", target: "User",
targetId: ctx.data.userId, targetId: ctx.data.userId,
after: { amount: ctx.data.amount }, after: { amount: ctx.data.amount },
}); });
return actionOk(); return actionOk();
}, },
); );
+113 -88
View File
@@ -23,102 +23,127 @@ export type RedeemState = { ok: boolean; message: string } | null;
* voucher schema carries a single `amount`, granted as the website credits * voucher schema carries a single `amount`, granted as the website credits
* wallet currency. * wallet currency.
*/ */
export async function redeem(_prev: RedeemState, formData: FormData): Promise<RedeemState> { export async function redeem(
const session = await auth(); _prev: RedeemState,
if (!session?.user?.id) { formData: FormData,
return { ok: false, message: "You must be signed in to redeem a voucher." }; ): Promise<RedeemState> {
} const session = await auth();
if (!session?.user?.id) {
return { ok: false, message: "You must be signed in to redeem a voucher." };
}
const userId = Number(session.user.id); const userId = Number(session.user.id);
if (!Number.isFinite(userId)) { if (!Number.isFinite(userId)) {
return { ok: false, message: "Your session is invalid. Please sign in again." }; return {
} ok: false,
message: "Your session is invalid. Please sign in again.",
};
}
const code = String(formData.get("code") ?? "") const code = String(formData.get("code") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
if (!code) { if (!code) {
return { ok: false, message: "Please enter a voucher code." }; return { ok: false, message: "Please enter a voucher code." };
} }
// Look up the code (website_shop_vouchers.code is unique). // Look up the code (website_shop_vouchers.code is unique).
let voucher: { let voucher: {
id: bigint; id: bigint;
amount: number; amount: number;
maxUses: number; maxUses: number;
useCount: number; useCount: number;
expiresAt: Date | null; expiresAt: Date | null;
} | null; } | null;
try { try {
voucher = await prisma.websiteShopVouchers.findUnique({ voucher = await prisma.websiteShopVouchers.findUnique({
where: { code }, where: { code },
select: { id: true, amount: true, maxUses: true, useCount: true, expiresAt: true }, select: {
}); id: true,
} catch { amount: true,
return { ok: false, message: "We couldn't reach the server. Please try again." }; maxUses: true,
} useCount: true,
expiresAt: true,
},
});
} catch {
return {
ok: false,
message: "We couldn't reach the server. Please try again.",
};
}
// Not found OR already expired -> generic "no active voucher" (matches AtomCMS). // Not found OR already expired -> generic "no active voucher" (matches AtomCMS).
if (!voucher || (voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())) { if (
return { ok: false, message: "No active voucher with the given code was found." }; !voucher ||
} (voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())
) {
return {
ok: false,
message: "No active voucher with the given code was found.",
};
}
// One redemption per user. // One redemption per user.
try { try {
const already = await prisma.websiteUsedShopVouchers.findFirst({ const already = await prisma.websiteUsedShopVouchers.findFirst({
where: { userId, voucherId: voucher.id }, where: { userId, voucherId: voucher.id },
select: { id: true }, select: { id: true },
}); });
if (already) { if (already) {
return { ok: false, message: "You can only use each shop voucher once." }; return { ok: false, message: "You can only use each shop voucher once." };
} }
} catch { } catch {
return { ok: false, message: "We couldn't reach the server. Please try again." }; return {
} ok: false,
message: "We couldn't reach the server. Please try again.",
};
}
// Record the redemption first so a successful grant can never be double-claimed. // Record the redemption first so a successful grant can never be double-claimed.
try { try {
await prisma.websiteUsedShopVouchers.create({ await prisma.websiteUsedShopVouchers.create({
data: { userId, voucherId: voucher.id }, data: { userId, voucherId: voucher.id },
}); });
} catch { } catch {
// Most likely a race (another tab redeemed it) — treat as already used. // Most likely a race (another tab redeemed it) — treat as already used.
return { ok: false, message: "You can only use each shop voucher once." }; return { ok: false, message: "You can only use each shop voucher once." };
} }
// Grant the reward. The voucher carries a single amount, delivered as credits. // Grant the reward. The voucher carries a single amount, delivered as credits.
try { try {
await sendCurrency({ rcon, db: prisma }, userId, "credits", voucher.amount); await sendCurrency({ rcon, db: prisma }, userId, "credits", voucher.amount);
} catch { } catch {
// sendCurrency already falls back to a direct DB write; if it still throws, // sendCurrency already falls back to a direct DB write; if it still throws,
// the used-row stands and the balance simply wasn't credited — surface that. // the used-row stands and the balance simply wasn't credited — surface that.
return { return {
ok: false, ok: false,
message: "Your voucher was accepted but the reward could not be delivered. Contact staff.", message:
}; "Your voucher was accepted but the reward could not be delivered. Contact staff.",
} };
}
// Bump use_count and expire the voucher once the cap is reached. // Bump use_count and expire the voucher once the cap is reached.
try { try {
const updated = await prisma.websiteShopVouchers.update({ const updated = await prisma.websiteShopVouchers.update({
where: { id: voucher.id }, where: { id: voucher.id },
data: { useCount: { increment: 1 } }, data: { useCount: { increment: 1 } },
select: { maxUses: true, useCount: true }, select: { maxUses: true, useCount: true },
}); });
if (updated.maxUses && updated.useCount >= updated.maxUses) { if (updated.maxUses && updated.useCount >= updated.maxUses) {
await prisma.websiteShopVouchers.update({ await prisma.websiteShopVouchers.update({
where: { id: voucher.id }, where: { id: voucher.id },
data: { expiresAt: new Date() }, data: { expiresAt: new Date() },
}); });
} }
} catch { } catch {
// Reward already delivered; the counter bump is best-effort. // Reward already delivered; the counter bump is best-effort.
} }
revalidatePath("/redeem"); revalidatePath("/redeem");
return { return {
ok: true, ok: true,
message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`, message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`,
}; };
} }
+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 });
}, },
); );
+107 -94
View File
@@ -6,107 +6,120 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Achievement = { type Achievement = {
name: string; name: string;
category: string; category: string;
level: number; level: number;
rewardAmount: number; rewardAmount: number;
rewardType: number; rewardType: number;
points: number | null; points: number | null;
progressNeeded: number; progressNeeded: number;
}; };
export default async function AdminAchievements() { export default async function AdminAchievements() {
const t = await getTranslations("pages.admin.achievements"); const t = await getTranslations("pages.admin.achievements");
let achievements: Achievement[]; let achievements: Achievement[];
try { try {
achievements = await prisma.achievements.findMany({ achievements = await prisma.achievements.findMany({
select: { select: {
name: true, name: true,
category: true, category: true,
level: true, level: true,
rewardAmount: true, rewardAmount: true,
rewardType: true, rewardType: true,
points: true, points: true,
progressNeeded: true, progressNeeded: true,
}, },
orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }], orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }],
}); });
} catch { } catch {
achievements = []; achievements = [];
} }
const groups = new Map<string, Achievement[]>(); const groups = new Map<string, Achievement[]>();
for (const a of achievements) { for (const a of achievements) {
const list = groups.get(a.category) ?? []; const list = groups.get(a.category) ?? [];
list.push(a); list.push(a);
groups.set(a.category, list); groups.set(a.category, list);
} }
const distinctNames = new Set(achievements.map((a) => a.name)).size; const distinctNames = new Set(achievements.map((a) => a.name)).size;
return ( return (
<main> <main>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Award size={20} className="text-[var(--admin-accent)]" /> <Award size={20} className="text-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1> <h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5"> {t("title")}
{t("subtitle", { rows: achievements.length, categories: groups.size })} </h1>
</p> <p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
</div> {t("subtitle", {
</div> rows: achievements.length,
categories: groups.size,
})}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("achievementRows")} value={achievements.length} icon="🏆" /> <StatusCard
<StatusCard label={t("distinctAchievements")} value={distinctNames} icon="🎖️" /> label={t("achievementRows")}
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" /> value={achievements.length}
</div> icon="🏆"
/>
<StatusCard
label={t("distinctAchievements")}
value={distinctNames}
icon="🎖️"
/>
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
</div>
{groups.size === 0 ? ( {groups.size === 0 ? (
<div className="admin-empty">{t("noAchievements")}</div> <div className="admin-empty">{t("noAchievements")}</div>
) : ( ) : (
[...groups.entries()].map(([category, rows]) => ( [...groups.entries()].map(([category, rows]) => (
<section key={category} className="mt-6"> <section key={category} className="mt-6">
<h2 className="admin-section-title"> <h2 className="admin-section-title">
{category}{" "} {category}{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]"> <span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{rows.length} {rows.length}
</span> </span>
</h2> </h2>
<div className="admin-card p-0 overflow-x-auto"> <div className="admin-card p-0 overflow-x-auto">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{t("colName")}</th> <th>{t("colName")}</th>
<th>{t("colLevel")}</th> <th>{t("colLevel")}</th>
<th>{t("colProgress")}</th> <th>{t("colProgress")}</th>
<th>{t("colRewardType")}</th> <th>{t("colRewardType")}</th>
<th>{t("colRewardAmount")}</th> <th>{t("colRewardAmount")}</th>
<th>{t("colPoints")}</th> <th>{t("colPoints")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((a) => ( {rows.map((a) => (
<tr key={`${a.name}-${a.level}`}> <tr key={`${a.name}-${a.level}`}>
<td> <td>
<strong>{a.name}</strong> <strong>{a.name}</strong>
</td> </td>
<td>{a.level}</td> <td>{a.level}</td>
<td>{a.progressNeeded}</td> <td>{a.progressNeeded}</td>
<td>{a.rewardType}</td> <td>{a.rewardType}</td>
<td>{a.rewardAmount}</td> <td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td> <td>{a.points ?? 0}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
)) ))
)} )}
</main> </main>
); );
} }
Loaded 100 of 735 files, more files were not shown because too many files have changed in this diff. Show more