Security hardening, code quality, and ESLint setup
- Remove production DB dump (db_backup_*.sql) and update.log from git tracking - Add DB backups to .gitignore - Replace all console.log/console.error with structured logger module - Translate Dutch error messages to English (link-discord.ts) - Remove dead code blocks (register-form.tsx false && pattern) - Add ESLint flat config with TypeScript, React, Next.js, jsx-a11y, and security plugins - Add Prettier config - Add eslint-plugin-security for security-aware linting - Fix all 119+ ESLint warnings across the codebase: - Resolve security/detect-object-injection with safe access patterns - Resolve security/detect-non-literal-fs-filename with path traversal validation - Replace <img> with next/image <Image> component - Remove unused variables and imports - Replace non-null assertions with proper type guards - Replace <a> with <Link> for internal navigation - Use next/script Script component for external scripts - Fix setState-in-useEffect anti-patterns (navbar-color-picker, logo-generator, theme-switcher) - Add lint and format scripts to package.json All checks: typecheck ✓, tests 58/58 ✓, lint 0 errors 0 warnings ✓
This commit is contained in:
1 parent
7f8c9afc0f
commit
942bc6fc8d
93 files changed
+2676
-379115
No files matched your search
@@ -10,3 +10,6 @@ src/generated/
|
|||||||
storage/logs/
|
storage/logs/
|
||||||
prod.log
|
prod.log
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Database backups
|
||||||
|
db_backup_*.sql
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 110,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,66 @@
|
|||||||
|
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 security from "eslint-plugin-security";
|
||||||
|
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
security.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
"@next/next": nextPlugin,
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"jsx-a11y": jsxA11y,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...nextPlugin.configs.recommended.rules,
|
||||||
|
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
|
||||||
|
"no-console": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/consistent-type-imports": "error",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"no-var": "error",
|
||||||
|
eqeqeq: ["error", "always", { null: "ignore" }],
|
||||||
|
|
||||||
|
"no-empty": ["warn", { allowEmptyCatch: true }],
|
||||||
|
"no-useless-assignment": "off",
|
||||||
|
"no-undef": "off",
|
||||||
|
|
||||||
|
"security/detect-object-injection": "warn",
|
||||||
|
"security/detect-non-literal-fs-filename": "warn",
|
||||||
|
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.test.ts", "**/*.test.tsx", "scripts/**"],
|
||||||
|
rules: {
|
||||||
|
"security/detect-object-injection": "off",
|
||||||
|
"security/detect-non-literal-fs-filename": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
".next/",
|
||||||
|
"node_modules/",
|
||||||
|
"src/generated/",
|
||||||
|
"public/",
|
||||||
|
"prisma/migrations/",
|
||||||
|
"db_backup_*.sql",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
"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",
|
||||||
|
"format": "prettier --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
@@ -39,6 +41,8 @@
|
|||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@next/eslint-plugin-next": "^16.2.10",
|
||||||
"@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",
|
||||||
@@ -48,11 +52,18 @@
|
|||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
|
"eslint": "^10.6.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-security": "^4.0.1",
|
||||||
"postcss": "^8.5.16",
|
"postcss": "^8.5.16",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
"tsx": "^4.22.5",
|
"tsx": "^4.22.5",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
"typescript-eslint": "^8.63.0",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
Generated
+2286
-10
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,3 @@
|
|||||||
import { createConnection } from "node:net";
|
|
||||||
import { readFileSync, readdirSync } from "node:fs";
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
import { resolve, dirname } from "node:path";
|
import { resolve, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -21,9 +20,9 @@ function getDbConfig(): { url: string; database: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureConnection(): Promise<void> {
|
async function ensureConnection(): Promise<void> {
|
||||||
const { database } = getDbConfig();
|
const { url } = getDbConfig();
|
||||||
const mysql = await import("mysql2/promise");
|
const mysql = await import("mysql2/promise");
|
||||||
const conn = await mysql.createConnection(process.env.DATABASE_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}\` (
|
||||||
@@ -38,8 +37,9 @@ async function ensureConnection(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getApplied(): Promise<Set<string>> {
|
async function getApplied(): Promise<Set<string>> {
|
||||||
|
const { url } = getDbConfig();
|
||||||
const mysql = await import("mysql2/promise");
|
const mysql = await import("mysql2/promise");
|
||||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
const conn = await mysql.createConnection(url);
|
||||||
try {
|
try {
|
||||||
const [rows] = await conn.execute(
|
const [rows] = await conn.execute(
|
||||||
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
||||||
@@ -66,8 +66,9 @@ function loadMigrations(): MigrationFile[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apply(migration: MigrationFile): Promise<void> {
|
async function apply(migration: MigrationFile): Promise<void> {
|
||||||
|
const { url } = getDbConfig();
|
||||||
const mysql = await import("mysql2/promise");
|
const mysql = await import("mysql2/promise");
|
||||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
const conn = await mysql.createConnection(url);
|
||||||
try {
|
try {
|
||||||
const statements = migration.sql
|
const statements = migration.sql
|
||||||
.split(";")
|
.split(";")
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const target = path.join(dir, `${code}.gif`);
|
const baseDir = path.resolve(dir);
|
||||||
|
const target = path.resolve(baseDir, `${code}.gif`);
|
||||||
|
if (!target.startsWith(baseDir + path.sep)) {
|
||||||
|
back("error", "Invalid path");
|
||||||
|
}
|
||||||
|
// 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");
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
targetType: "help_center_category",
|
targetType: "help_center_category",
|
||||||
targetId: Number(entry.id),
|
targetId: Number(entry.id),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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
|
||||||
create: { key, value, comment: COMMENTS[key] ?? null },
|
create: { key, value, comment: COMMENTS[key] ?? null },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { writeFile, mkdir } from "fs/promises";
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
@@ -17,13 +16,17 @@ export async function uploadMedia(formData: FormData): Promise<void> {
|
|||||||
if (file.size > MAX_SIZE) throw new Error("File too large (max 5MB)");
|
if (file.size > MAX_SIZE) throw new Error("File too large (max 5MB)");
|
||||||
if (!ALLOWED.includes(file.type)) throw new Error("Invalid file type");
|
if (!ALLOWED.includes(file.type)) throw new Error("Invalid file type");
|
||||||
|
|
||||||
const dir = path.join(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
await mkdir(dir, { recursive: true });
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
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();
|
||||||
await writeFile(path.join(dir, name), Buffer.from(bytes));
|
const filePath = path.resolve(baseDir, name);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await writeFile(filePath, Buffer.from(bytes));
|
||||||
|
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/admin/media");
|
||||||
@@ -32,10 +35,9 @@ export async function uploadMedia(formData: FormData): Promise<void> {
|
|||||||
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("fs/promises");
|
||||||
const dir = path.join(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
const filePath = path.join(dir, name);
|
const filePath = path.resolve(baseDir, name);
|
||||||
// Prevent path traversal
|
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||||
if (name.includes("..") || name.includes("/")) return;
|
|
||||||
try {
|
try {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -52,13 +54,17 @@ export async function uploadMediaAndReturn(formData: FormData): Promise<string>
|
|||||||
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 dir = path.join(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
await mkdir(dir, { recursive: true });
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
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();
|
||||||
await writeFile(path.join(dir, name), Buffer.from(bytes));
|
const filePath = path.resolve(baseDir, name);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) return "";
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await writeFile(filePath, Buffer.from(bytes));
|
||||||
|
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/admin/media");
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export async function savePoints(formData: FormData): Promise<void> {
|
|||||||
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
|
||||||
update: { value: values[key] },
|
update: { value: values[key] },
|
||||||
|
// 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" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
|||||||
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") ?? "");
|
const name = String(formData.get("preset") ?? "");
|
||||||
|
// 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");
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function postComment(formData: FormData): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let slug: string | null = 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({
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function toggleReaction(formData: FormData): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let slug: string | null = 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({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function precheckLogin(
|
|||||||
|
|
||||||
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 = 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 },
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { CurrencyType } from "@/lib/services/currency";
|
|
||||||
|
|
||||||
const PATH = "/admin/commandocentrum";
|
const PATH = "/admin/commandocentrum";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { redirect } from "next/navigation";
|
|||||||
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 { sendCurrency } from "@/lib/services/send-currency";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +52,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
const rawId = String(formData.get("id") ?? "").trim();
|
const rawId = String(formData.get("id") ?? "").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" = "fail";
|
let outcome: "bought" | "invalid" | "credits" | "fail";
|
||||||
let boughtCode = "";
|
let boughtCode = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function createTicket(formData: FormData): Promise<void> {
|
|||||||
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 ip = 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 = {
|
||||||
|
|||||||
@@ -2,33 +2,32 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
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 "Niet ingelogd";
|
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 "Ongeldig Discord ID";
|
return "Invalid Discord ID format";
|
||||||
}
|
}
|
||||||
|
|
||||||
const discordIdClean = discordId.trim();
|
const discordIdClean = discordId.trim();
|
||||||
|
|
||||||
// Check if this Discord ID is already linked to another account.
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.socialAccounts.findUnique({
|
const existing = await prisma.socialAccounts.findUnique({
|
||||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
});
|
});
|
||||||
if (existing && Number(existing.userId) !== userId) {
|
if (existing && Number(existing.userId) !== userId) {
|
||||||
return "Dit Discord ID is al gekoppeld aan een ander account";
|
return "This Discord ID is already linked to another account";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return "Fout bij controleren Discord ID";
|
return "Failed to check Discord ID";
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upsert: create or update the social_accounts entry.
|
|
||||||
await prisma.socialAccounts.upsert({
|
await prisma.socialAccounts.upsert({
|
||||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||||
create: {
|
create: {
|
||||||
@@ -41,15 +40,14 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
|
|||||||
update: { userId: BigInt(userId), updatedAt: new Date() },
|
update: { userId: BigInt(userId), updatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark user as verified.
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { mailVerified: "1" },
|
data: { mailVerified: "1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return null; // success
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[link-discord] Failed:", (e as Error).message);
|
logger.error("Failed to link Discord account", { module: "link-discord", error: (e as Error).message });
|
||||||
return "Fout bij koppelen van Discord account";
|
return "Failed to link Discord account";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ export async function postShout(formData: FormData): Promise<void> {
|
|||||||
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 ip = 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 = {
|
||||||
|
|||||||
+23
-10
@@ -23,11 +23,16 @@ export async function saveFavicon(formData: FormData): Promise<{ success: boolea
|
|||||||
};
|
};
|
||||||
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 dir = path.join(process.cwd(), FAVICON_DIR);
|
const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
|
||||||
const filePath = path.join(dir, filename);
|
const filePath = path.resolve(baseDir, filename);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||||
|
return { success: false, error: "Invalid path" };
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await mkdir(dir, { recursive: true });
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await mkdir(baseDir, { recursive: true });
|
||||||
|
// 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}`;
|
||||||
@@ -37,9 +42,13 @@ export async function saveFavicon(formData: FormData): Promise<{ success: boolea
|
|||||||
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) {
|
if (oldUrl && 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("/")) {
|
||||||
try {
|
const oldPath = path.resolve(baseDir, oldName);
|
||||||
await unlink(path.join(dir, oldName));
|
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||||
} catch { /* ignore if file doesn't exist */ }
|
try {
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await unlink(oldPath);
|
||||||
|
} catch { /* ignore if file doesn't exist */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +72,16 @@ export async function deleteFavicon(): Promise<{ success: boolean; error?: strin
|
|||||||
try {
|
try {
|
||||||
const oldUrl = await siteSettings.get("cms_favicon");
|
const oldUrl = await siteSettings.get("cms_favicon");
|
||||||
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) {
|
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) {
|
||||||
const dir = path.join(process.cwd(), FAVICON_DIR);
|
const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
|
||||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||||
try {
|
const oldPath = path.resolve(baseDir, oldName);
|
||||||
await unlink(path.join(dir, oldName));
|
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||||
} catch { /* ignore */ }
|
try {
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await unlink(oldPath);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -13,14 +13,18 @@ export async function saveLogo(formData: FormData): Promise<{ success: boolean;
|
|||||||
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 mimeExt: Record<string, string> = { "image/png": "png", "image/gif": "gif", "image/jpeg": "jpg", "image/webp": "webp" };
|
const ext = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
|
||||||
const ext = mimeExt[file.type] ?? "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 dir = path.join(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
const filePath = path.join(dir, filename);
|
const filePath = path.resolve(baseDir, filename);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||||
|
return { success: false, error: "Invalid path" };
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await mkdir(dir, { recursive: true });
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await mkdir(baseDir, { recursive: true });
|
||||||
|
// 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}`;
|
||||||
@@ -48,13 +52,17 @@ export async function saveLogoFromUrl(gifUrl: string): Promise<{ success: boolea
|
|||||||
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 mimeExt: Record<string, string> = { "image/png": "png", "image/gif": "gif", "image/jpeg": "jpg", "image/webp": "webp" };
|
const ext = contentType === "image/png" ? "png" : contentType === "image/gif" ? "gif" : contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "gif";
|
||||||
const ext = mimeExt[contentType] ?? "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 dir = path.join(process.cwd(), MEDIA_DIR);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
const filePath = path.join(dir, filename);
|
const filePath = path.resolve(baseDir, filename);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||||
|
return { success: false, error: "Invalid path" };
|
||||||
|
}
|
||||||
|
|
||||||
await mkdir(dir, { recursive: true });
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
await mkdir(baseDir, { recursive: true });
|
||||||
|
// 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}`;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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 { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
async function sessionUserId(): Promise<number> {
|
async function sessionUserId(): Promise<number> {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function redeem(
|
|||||||
maxUses: number;
|
maxUses: number;
|
||||||
useCount: number;
|
useCount: number;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
} | null = null;
|
} | null;
|
||||||
try {
|
try {
|
||||||
voucher = await prisma.websiteShopVouchers.findUnique({
|
voucher = await prisma.websiteShopVouchers.findUnique({
|
||||||
where: { code },
|
where: { code },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type Achievement = {
|
|||||||
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: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function AdminAds() {
|
export default async function AdminAds() {
|
||||||
const t = await getTranslations("pages.admin.ads");
|
const t = await getTranslations("pages.admin.ads");
|
||||||
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = [];
|
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>>;
|
||||||
try {
|
try {
|
||||||
ads = await prisma.websiteAds.findMany({
|
ads = await prisma.websiteAds.findMany({
|
||||||
orderBy: { id: "desc" },
|
orderBy: { id: "desc" },
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function Badge({ label, color }: { label: string; color: string }) {
|
|||||||
export default async function AdminAlerts() {
|
export default async function AdminAlerts() {
|
||||||
const t = await getTranslations("pages.admin.alerts");
|
const t = await getTranslations("pages.admin.alerts");
|
||||||
|
|
||||||
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>> = [];
|
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
|
||||||
try {
|
try {
|
||||||
alerts = await prisma.alertLogs.findMany({
|
alerts = await prisma.alertLogs.findMany({
|
||||||
orderBy: { id: "desc" },
|
orderBy: { id: "desc" },
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function formatDate(d: Date | null): string {
|
|||||||
|
|
||||||
export default async function AdminApplications() {
|
export default async function AdminApplications() {
|
||||||
const t = await getTranslations("pages.admin.applications");
|
const t = await getTranslations("pages.admin.applications");
|
||||||
let applications: ApplicationRow[] = [];
|
let applications: ApplicationRow[];
|
||||||
try {
|
try {
|
||||||
applications = await prisma.websiteStaffApplications.findMany({
|
applications = await prisma.websiteStaffApplications.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function AdminBadges({
|
|||||||
const t = await getTranslations("pages.admin.badges");
|
const t = await getTranslations("pages.admin.badges");
|
||||||
const { uploaded, error } = await searchParams;
|
const { uploaded, error } = await searchParams;
|
||||||
|
|
||||||
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>> = [];
|
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>>;
|
||||||
try {
|
try {
|
||||||
badges = await prisma.websiteBadges.findMany({
|
badges = await prisma.websiteBadges.findMany({
|
||||||
orderBy: { badgeName: "asc" },
|
orderBy: { badgeName: "asc" },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Calendar } from "lucide-react";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
import { saveFavicon, deleteFavicon } from "@/actions/save-favicon";
|
import { saveFavicon, deleteFavicon } from "@/actions/save-favicon";
|
||||||
import { FaviconGenerator } from "./favicon-generator";
|
import { FaviconGenerator } from "./favicon-generator";
|
||||||
|
|
||||||
@@ -70,10 +71,13 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
|
|||||||
{t("current")}
|
{t("current")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<img
|
<Image
|
||||||
src={preview}
|
src={preview}
|
||||||
alt="Favicon preview"
|
alt="Favicon preview"
|
||||||
className="w-16 h-16 rounded-lg border-2 border-[var(--border-subtle)] object-contain bg-white"
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="rounded-lg border-2 border-[var(--border-subtle)] object-contain bg-white"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-[var(--color-text-muted)] break-all">{preview}</div>
|
<div className="text-xs text-[var(--color-text-muted)] break-all">{preview}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
|
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
@@ -90,9 +91,6 @@ function getNavGroups(t: (key: string) => string) {
|
|||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const t = await getTranslations("pages.admin.nav");
|
|
||||||
const navGroups = getNavGroups(t);
|
|
||||||
|
|
||||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||||
const u = await prisma.user
|
const u = await prisma.user
|
||||||
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
|
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
|
||||||
@@ -147,13 +145,13 @@ async function Sidebar({ staff }: { staff: { id: number; username: string; rank:
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="px-3 py-3 border-t border-white/[0.06]">
|
<div className="px-3 py-3 border-t border-white/[0.06]">
|
||||||
<a
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[#c8cbe0]/60 text-xs font-medium hover:text-white hover:bg-white/5 transition-all duration-150 no-underline"
|
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[#c8cbe0]/60 text-xs font-medium hover:text-white hover:bg-white/5 transition-all duration-150 no-underline"
|
||||||
>
|
>
|
||||||
<LogOut size={14} />
|
<LogOut size={14} />
|
||||||
<span>{t("backToSite")}</span>
|
<span>{t("backToSite")}</span>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { createTrack, deleteTrack, toggleTrack } from '@/actions/admin-radio-autodj';
|
import { createTrack, deleteTrack, toggleTrack } from '@/actions/admin-radio-autodj';
|
||||||
import { StatusCard } from '@/components/admin/dashboard';
|
import { StatusCard } from '@/components/admin/dashboard';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
@@ -220,12 +221,13 @@ export default async function AdminRadioAutoDjPage() {
|
|||||||
<td>
|
<td>
|
||||||
<span className="inline-flex gap-2 items-center">
|
<span className="inline-flex gap-2 items-center">
|
||||||
{track.artworkUrl ? (
|
{track.artworkUrl ? (
|
||||||
<img
|
<Image
|
||||||
src={track.artworkUrl}
|
src={track.artworkUrl}
|
||||||
alt=""
|
alt=""
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
style={{ borderRadius: 4, objectFit: 'cover' }}
|
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<strong>{track.title}</strong>
|
<strong>{track.title}</strong>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
} from '@/actions/admin-radio-extra';
|
} from '@/actions/admin-radio-extra';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { StatusCard } from '@/components/admin/dashboard';
|
import { StatusCard } from '@/components/admin/dashboard';
|
||||||
import { Image } from 'lucide-react';
|
import NextImage from 'next/image';
|
||||||
|
import { Image as LucideImage } from 'lucide-react';
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -66,7 +67,7 @@ export default async function AdminRadioBannersPage() {
|
|||||||
|
|
||||||
<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(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
||||||
<Image size={20} className="text-[var(--color-primary)]" />
|
<LucideImage size={20} className="text-[var(--color-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("banners.heading")}</h1>
|
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("banners.heading")}</h1>
|
||||||
@@ -176,10 +177,13 @@ export default async function AdminRadioBannersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{b.imagePath ? (
|
{b.imagePath ? (
|
||||||
<img
|
<NextImage
|
||||||
src={b.imagePath}
|
src={b.imagePath}
|
||||||
alt={b.title ?? t("banners.bannerAlt")}
|
alt={b.title ?? t("banners.bannerAlt")}
|
||||||
|
width={800}
|
||||||
|
height={200}
|
||||||
className="article-img my-2"
|
className="article-img my-2"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default async function AdminRadioEmbedPage() {
|
|||||||
{t("embedPage.livePreviewText")}
|
{t("embedPage.livePreviewText")}
|
||||||
</p>
|
</p>
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
{ }
|
||||||
<audio controls preload="none" src={streamUrl} style={{ width: '100%' }}>
|
<audio controls preload="none" src={streamUrl} style={{ width: '100%' }}>
|
||||||
{t("embedPage.audioUnsupported")}
|
{t("embedPage.audioUnsupported")}
|
||||||
</audio>
|
</audio>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickString(obj: Record<string, unknown>, key: string): string | null {
|
function pickString(obj: Record<string, unknown>, key: string): string | null {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
|
||||||
const v = obj[key];
|
const v = obj[key];
|
||||||
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
|
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
|
||||||
}
|
}
|
||||||
@@ -110,6 +111,7 @@ function findNumberDeep(value: unknown, keys: string[], depth = 0): number | nul
|
|||||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
if (!isRecord(value)) return null;
|
if (!isRecord(value)) return null;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||||
const v = value[key];
|
const v = value[key];
|
||||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||||
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) {
|
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings";
|
import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export default async function AdminTheme({
|
|||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- k from hardcoded array
|
||||||
background: palette[k],
|
background: palette[k],
|
||||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
||||||
marginLeft: -3,
|
marginLeft: -3,
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { 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 { CurrencyType } from "@/lib/services/currency";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
const giveCurrency = async ({
|
const giveCurrency = async ({
|
||||||
rconClient,
|
rconClient: _rconClient,
|
||||||
db,
|
db: _db,
|
||||||
userId,
|
userId,
|
||||||
type,
|
type,
|
||||||
amount,
|
amount,
|
||||||
@@ -158,7 +158,7 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Admin users actions error:", error);
|
logger.error("Admin users actions error", { module: "admin/users/actions", error: String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: "Internal server error" },
|
{ success: false, message: "Internal server error" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiJson } from "@/lib/api";
|
|||||||
import { bearerUserId } from "@/lib/api-auth";
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -314,6 +315,7 @@ export async function GET(req: Request) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const rarity = Object.fromEntries(
|
const rarity = Object.fromEntries(
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||||
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
|
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
|
||||||
|
|
||||||
@@ -324,7 +326,7 @@ export async function GET(req: Request) {
|
|||||||
leaderboards: { totalBadges, achievementLevel, rarity },
|
leaderboards: { totalBadges, achievementLevel, rarity },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Badge leaderboard error:", err);
|
logger.error("Badge leaderboard error", { module: "badges/leaderboard", error: String(err) });
|
||||||
return apiJson({
|
return apiJson({
|
||||||
viewerUserId: 0,
|
viewerUserId: 0,
|
||||||
badgeStats: [],
|
badgeStats: [],
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export async function GET(req: Request) {
|
|||||||
const rows =
|
const rows =
|
||||||
type === "credits"
|
type === "credits"
|
||||||
? await loadCreditsRows()
|
? await loadCreditsRows()
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
||||||
: await loadCurrencyRows(CURRENCY_TYPE[type]);
|
: await loadCurrencyRows(CURRENCY_TYPE[type]);
|
||||||
|
|
||||||
return apiJson({ type, data: rows }, { status: 200 });
|
return apiJson({ type, data: rows }, { status: 200 });
|
||||||
|
|||||||
@@ -23,11 +23,17 @@ export async function GET(
|
|||||||
return new NextResponse("Forbidden", { status: 403 });
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(process.cwd(), MEDIA_DIR, name);
|
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||||
|
const filePath = path.resolve(baseDir, name);
|
||||||
|
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||||
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
return new NextResponse("Not found", { status: 404 });
|
return new NextResponse("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
const bytes = await readFile(filePath);
|
const bytes = await readFile(filePath);
|
||||||
const mime: Record<string, string> = {
|
const mime: Record<string, string> = {
|
||||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
@@ -36,6 +42,7 @@ export async function GET(
|
|||||||
|
|
||||||
return new NextResponse(bytes, {
|
return new NextResponse(bytes, {
|
||||||
headers: {
|
headers: {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||||
"Cache-Control": "public, max-age=86400",
|
"Cache-Control": "public, max-age=86400",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export const dynamic = "force-dynamic";
|
|||||||
const MEDIA_DIR = "assets/images/media";
|
const MEDIA_DIR = "assets/images/media";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const dir = path.join(process.cwd(), "public", MEDIA_DIR);
|
const dir = path.resolve(process.cwd(), "public", MEDIA_DIR);
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
return NextResponse.json({ files: [] });
|
return NextResponse.json({ files: [] });
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
const files = readdirSync(dir)
|
const files = readdirSync(dir)
|
||||||
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { sendCurrency } from "@/lib/services/send-currency";
|
import { sendCurrency } from "@/lib/services/send-currency";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
try {
|
try {
|
||||||
result = await captureOrder(orderId);
|
result = await captureOrder(orderId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[paypal/capture]", (e as Error).message);
|
logger.error("PayPal capture failed", { module: "paypal/capture", error: (e as Error).message });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Could not capture the PayPal payment. If you were charged, contact staff." },
|
{ error: "Could not capture the PayPal payment. If you were charged, contact staff." },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
@@ -127,7 +128,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[paypal/capture] record failed", (e as Error).message);
|
logger.error("PayPal capture record failed", { module: "paypal/capture", error: (e as Error).message });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Payment captured but could not be recorded. Contact staff with your order id." },
|
{ error: "Payment captured but could not be recorded. Contact staff with your order id." },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
@@ -138,7 +139,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
try {
|
try {
|
||||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[paypal/capture] credit failed", (e as Error).message);
|
logger.error("PayPal capture credit failed", { module: "paypal/capture", error: (e as Error).message });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PAYPAL_CURRENCY,
|
PAYPAL_CURRENCY,
|
||||||
} from "@/lib/services/paypal";
|
} from "@/lib/services/paypal";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
credits,
|
credits,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[paypal/create]", (e as Error).message);
|
logger.error("PayPal create order failed", { module: "paypal/create", error: (e as Error).message });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function findCount(value: unknown, depth = 0): number | null {
|
|||||||
|
|
||||||
const keys = ["current", "total", "num_listeners", "listeners", "unique_listeners", "count"];
|
const keys = ["current", "total", "num_listeners", "listeners", "unique_listeners", "count"];
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||||
const v = value[key];
|
const v = value[key];
|
||||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) {
|
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function ToolbarBtn({ onClick, title, children, href }: {
|
|||||||
export function ClientView({ ticket, clientUrl, hotelName, initialOnline }: {
|
export function ClientView({ ticket, clientUrl, hotelName, initialOnline }: {
|
||||||
ticket: string; clientUrl: string; hotelName: string; initialOnline: number;
|
ticket: string; clientUrl: string; hotelName: string; initialOnline: number;
|
||||||
}) {
|
}) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [_isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [onlineCount, setOnlineCount] = useState(initialOnline);
|
const [onlineCount, setOnlineCount] = useState(initialOnline);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ const ERROR_NOTE: Record<string, string> = {
|
|||||||
fail: "Something went wrong. Please try again.",
|
fail: "Something went wrong. Please try again.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getErrorNote(error: string): string {
|
||||||
|
if (error === "invalid") return ERROR_NOTE.invalid;
|
||||||
|
if (error === "credits") return ERROR_NOTE.credits;
|
||||||
|
return ERROR_NOTE.fail;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DrawBadgePage({
|
export default async function DrawBadgePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -104,7 +110,7 @@ export default async function DrawBadgePage({
|
|||||||
role="alert"
|
role="alert"
|
||||||
style={{ margin: "1rem 0 0", fontWeight: 700, color: "var(--color-danger)" }}
|
style={{ margin: "1rem 0 0", fontWeight: 700, color: "var(--color-danger)" }}
|
||||||
>
|
>
|
||||||
{ERROR_NOTE[error] ?? ERROR_NOTE.fail}
|
{getErrorNote(error)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,9 +39,9 @@ export default function Error({
|
|||||||
<button type="button" className="btn btn-primary" onClick={() => reset()}>
|
<button type="button" className="btn btn-primary" onClick={() => reset()}>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
<a className="btn btn-outline" href="/">
|
<Link className="btn btn-outline" href="/">
|
||||||
Back home
|
Back home
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{error.digest ? (
|
{error.digest ? (
|
||||||
<p className="muted" style={{ marginTop: "0.75rem", fontSize: "0.75rem" }}>
|
<p className="muted" style={{ marginTop: "0.75rem", fontSize: "0.75rem" }}>
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export default async function GuildPage({
|
|||||||
const usersById = new Map(users.map((u) => [u.id, u]));
|
const usersById = new Map(users.map((u) => [u.id, u]));
|
||||||
const sinceById = new Map(memberRows.map((m) => [m.userId, m.memberSince]));
|
const sinceById = new Map(memberRows.map((m) => [m.userId, m.memberSince]));
|
||||||
|
|
||||||
|
const guildOwnerUserId = guild.userId;
|
||||||
|
|
||||||
const members: MemberView[] = memberIds
|
const members: MemberView[] = memberIds
|
||||||
.map((uid) => {
|
.map((uid) => {
|
||||||
const u = usersById.get(uid);
|
const u = usersById.get(uid);
|
||||||
@@ -108,7 +110,7 @@ export default async function GuildPage({
|
|||||||
look: u.look,
|
look: u.look,
|
||||||
motto: u.motto,
|
motto: u.motto,
|
||||||
memberSince: sinceById.get(uid) ?? 0,
|
memberSince: sinceById.get(uid) ?? 0,
|
||||||
isOwner: uid === guild!.userId,
|
isOwner: uid === guildOwnerUserId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((m): m is MemberView => m !== null)
|
.filter((m): m is MemberView => m !== null)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default async function HelpCategoryPage({
|
|||||||
let cat: HelpCategory | null = null;
|
let cat: HelpCategory | null = null;
|
||||||
try {
|
try {
|
||||||
cat = await prisma.websiteHelpCenterCategories.findUnique({
|
cat = await prisma.websiteHelpCenterCategories.findUnique({
|
||||||
where: { id: categoryId! },
|
where: { id: categoryId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -81,7 +81,7 @@ export default async function HelpCategoryPage({
|
|||||||
let siblings: HelpCategoryLink[] = [];
|
let siblings: HelpCategoryLink[] = [];
|
||||||
try {
|
try {
|
||||||
siblings = await prisma.websiteHelpCenterCategories.findMany({
|
siblings = await prisma.websiteHelpCenterCategories.findMany({
|
||||||
where: { id: { not: categoryId! } },
|
where: { id: { not: categoryId } },
|
||||||
orderBy: { position: "asc" },
|
orderBy: { position: "asc" },
|
||||||
select: { id: true, name: true, position: true },
|
select: { id: true, name: true, position: true },
|
||||||
take: 50,
|
take: 50,
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
import { getLocale, getMessages } from "next-intl/server";
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import { Nunito, Pixelify_Sans } from "next/font/google";
|
import { Nunito, Pixelify_Sans } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import Script from "next/script";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Navigation } from "@/components/navigation";
|
import { Navigation } from "@/components/navigation";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
@@ -67,7 +68,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|||||||
<html lang={locale} className={`app ${nunito.variable} ${pixelFont.variable}`}>
|
<html lang={locale} className={`app ${nunito.variable} ${pixelFont.variable}`}>
|
||||||
<head>
|
<head>
|
||||||
<meta name="theme-default-dark" content={String(defaultDark)} />
|
<meta name="theme-default-dark" content={String(defaultDark)} />
|
||||||
<script src="/scripts/theme-init.js" />
|
<Script src="/scripts/theme-init.js" strategy="beforeInteractive" />
|
||||||
<link rel="preconnect" href="https://www.habbo.com" />
|
<link rel="preconnect" href="https://www.habbo.com" />
|
||||||
<link rel="dns-prefetch" href="https://www.habbo.com" />
|
<link rel="dns-prefetch" href="https://www.habbo.com" />
|
||||||
{nitroUrl && nitroUrl.startsWith("http") ? (
|
{nitroUrl && nitroUrl.startsWith("http") ? (
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ async function loadSettingsRows(
|
|||||||
.map((t) => {
|
.map((t) => {
|
||||||
const u = byId.get(t.userId as number);
|
const u = byId.get(t.userId as number);
|
||||||
if (!u) return null;
|
if (!u) return null;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- field is union of known keys
|
||||||
return { username: u.username, look: u.look, value: Number(t[field] ?? 0) };
|
return { username: u.username, look: u.look, value: Number(t[field] ?? 0) };
|
||||||
})
|
})
|
||||||
.filter((r): r is Row => r !== null);
|
.filter((r): r is Row => r !== null);
|
||||||
@@ -123,13 +124,14 @@ export default async function LeaderboardPage({
|
|||||||
const t = await getTranslations("pages.leaderboard");
|
const t = await getTranslations("pages.leaderboard");
|
||||||
const { type } = await searchParams;
|
const { type } = await searchParams;
|
||||||
const active: TabKey = TABS.some((t) => t.key === type) ? (type as TabKey) : "credits";
|
const active: TabKey = TABS.some((t) => t.key === type) ? (type as TabKey) : "credits";
|
||||||
const activeTab = TABS.find((t) => t.key === active)!;
|
const activeTab = TABS.find((t) => t.key === active) ?? TABS[0];
|
||||||
|
|
||||||
const [rows, imagerBase] = await Promise.all([
|
const [rows, imagerBase] = await Promise.all([
|
||||||
active === "credits"
|
active === "credits"
|
||||||
? loadCreditsRows()
|
? loadCreditsRows()
|
||||||
: active === "diamonds" || active === "duckets"
|
: active === "diamonds" || active === "duckets"
|
||||||
? loadCurrencyRows(CURRENCY_TYPE[active])
|
// eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
|
||||||
|
? loadCurrencyRows(CURRENCY_TYPE[active])
|
||||||
: loadSettingsRows(SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD]),
|
: loadSettingsRows(SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD]),
|
||||||
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
||||||
]);
|
]);
|
||||||
|
|||||||
+8
-1
@@ -44,6 +44,13 @@ const ERROR_MESSAGES: Record<string, string> = {
|
|||||||
error: "Something went wrong while claiming your reward. Please try again.",
|
error: "Something went wrong while claiming your reward. Please try again.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: string): string {
|
||||||
|
if (error === "not_enough") return ERROR_MESSAGES.not_enough;
|
||||||
|
if (error === "no_referrals") return ERROR_MESSAGES.no_referrals;
|
||||||
|
if (error === "bad_config") return ERROR_MESSAGES.bad_config;
|
||||||
|
return ERROR_MESSAGES.error;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function MePage({ searchParams }: { searchParams: SearchParams }) {
|
export default async function MePage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
@@ -147,7 +154,7 @@ export default async function MePage({ searchParams }: { searchParams: SearchPar
|
|||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div role="alert" style={feedbackStyle("error")}>
|
<div role="alert" style={feedbackStyle("error")}>
|
||||||
{ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error}
|
{getErrorMessage(error)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default async function ArticlePage({
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const loggedIn = Boolean(session?.user?.id);
|
const loggedIn = Boolean(session?.user?.id);
|
||||||
const sessionUserId = loggedIn ? Number(session!.user!.id) : null;
|
const sessionUserId = session?.user?.id ? Number(session.user.id) : null;
|
||||||
|
|
||||||
// Reaction counts grouped by reaction type for this article, plus the
|
// Reaction counts grouped by reaction type for this article, plus the
|
||||||
// signed-in user's currently-active reaction (so its button reads as pressed).
|
// signed-in user's currently-active reaction (so its button reads as pressed).
|
||||||
|
|||||||
+10
-5
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -53,11 +54,15 @@ export default async function NewsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{a.image ? (
|
{a.image ? (
|
||||||
<img
|
<div style={{ position: "relative", width: "100%", aspectRatio: "16/9" }}>
|
||||||
src={a.image}
|
<Image
|
||||||
alt=""
|
src={a.image}
|
||||||
style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover" }}
|
alt=""
|
||||||
/>
|
fill
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{ width: "100%", aspectRatio: "16/9", backgroundColor: "color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))" }}
|
style={{ width: "100%", aspectRatio: "16/9", backgroundColor: "color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))" }}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function RadioContestDetailPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const t = await getTranslations("pages.radioContests");
|
await getTranslations("pages.radioContests");
|
||||||
|
|
||||||
let contestId: bigint;
|
let contestId: bigint;
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export default async function RadioContestDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contest = await prisma.radioContests
|
const contest = await prisma.radioContests
|
||||||
.findUnique({ where: { id: contestId! } })
|
.findUnique({ where: { id: contestId } })
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
if (!contest) notFound();
|
if (!contest) notFound();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function RadioGiveawayDetailPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const t = await getTranslations("pages.radioGiveaways");
|
await getTranslations("pages.radioGiveaways");
|
||||||
|
|
||||||
let giveawayId: bigint;
|
let giveawayId: bigint;
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export default async function RadioGiveawayDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const giveaway = await prisma.radioGiveaways
|
const giveaway = await prisma.radioGiveaways
|
||||||
.findUnique({ where: { id: giveawayId! } })
|
.findUnique({ where: { id: giveawayId } })
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
if (!giveaway) notFound();
|
if (!giveaway) notFound();
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const RADIO_SIDEBAR_LINKS = [
|
|||||||
{ key: "leaderboard", href: "/radio/leaderboard", icon: "🏆" },
|
{ key: "leaderboard", href: "/radio/leaderboard", icon: "🏆" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SIDEBAR_KEYS = RADIO_SIDEBAR_LINKS.map((l) => l.key);
|
|
||||||
|
|
||||||
export default async function RadioLayout({ children }: { children: ReactNode }) {
|
export default async function RadioLayout({ children }: { children: ReactNode }) {
|
||||||
const t = await getTranslations("pages.radio");
|
const t = await getTranslations("pages.radio");
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ function formatDate(d: Date | null | undefined): string {
|
|||||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
|
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
|
|
||||||
|
|
||||||
function formatDay(d: Date): string {
|
|
||||||
return DAYS[d.getUTCDay()] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RadioRequestsPage() {
|
export default async function RadioRequestsPage() {
|
||||||
const t = await getTranslations("pages.radioRequests");
|
const t = await getTranslations("pages.radioRequests");
|
||||||
const genericT = await getTranslations("pages.radio");
|
const genericT = await getTranslations("pages.radio");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { ContentCard, EmptyState, OnlineBadge, RankBadge } from "@/components/public/ui";
|
import { ContentCard, EmptyState, OnlineBadge, RankBadge } from "@/components/public/ui";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -53,13 +54,13 @@ export default async function RankingsPage() {
|
|||||||
style={{ display: "flex", gap: "0.9rem", alignItems: "center" }}
|
style={{ display: "flex", gap: "0.9rem", alignItems: "center" }}
|
||||||
>
|
>
|
||||||
<RankBadge position={i + 1} />
|
<RankBadge position={i + 1} />
|
||||||
{/* biome-ignore lint/performance/noImgElement: external avatar imager */}
|
<Image
|
||||||
<img
|
|
||||||
className="avatar"
|
className="avatar"
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={`${u.username} avatar`}
|
alt={`${u.username} avatar`}
|
||||||
width={50}
|
width={50}
|
||||||
height={90}
|
height={90}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
|
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default async function RareCategoryPage({
|
|||||||
let cat: { id: bigint; name: string; badge: string } | null = null;
|
let cat: { id: bigint; name: string; badge: string } | null = null;
|
||||||
try {
|
try {
|
||||||
cat = await prisma.websiteRareValueCategories.findUnique({
|
cat = await prisma.websiteRareValueCategories.findUnique({
|
||||||
where: { id: categoryId! },
|
where: { id: categoryId },
|
||||||
select: { id: true, name: true, badge: true },
|
select: { id: true, name: true, badge: true },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -65,7 +65,7 @@ export default async function RareCategoryPage({
|
|||||||
let rares: RareRow[] = [];
|
let rares: RareRow[] = [];
|
||||||
try {
|
try {
|
||||||
rares = await prisma.websiteRareValues.findMany({
|
rares = await prisma.websiteRareValues.findMany({
|
||||||
where: { categoryId: categoryId! },
|
where: { categoryId: categoryId },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const STATE_LABELS: Record<string, { label: string; icon: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function describeState(state: string): { label: string; icon: string } {
|
function describeState(state: string): { label: string; icon: string } {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- STATE_LABELS has known keys, fallback provided
|
||||||
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export interface OnlineUser {
|
export interface OnlineUser {
|
||||||
@@ -18,7 +19,7 @@ export function StatusCard({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
state = "neutral",
|
state: _state = "neutral",
|
||||||
icon,
|
icon,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -122,11 +123,12 @@ export function OnlineUsersWidget({
|
|||||||
className="flex items-center gap-3 p-3 bg-[var(--color-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--color-text-muted)]/10 hover:translate-x-0.5"
|
className="flex items-center gap-3 p-3 bg-[var(--color-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--color-text-muted)]/10 hover:translate-x-0.5"
|
||||||
>
|
>
|
||||||
<div className="relative flex-none w-[50px] h-[62px]">
|
<div className="relative flex-none w-[50px] h-[62px]">
|
||||||
<img
|
<Image
|
||||||
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
className="w-full h-full rounded object-cover border border-[var(--border-subtle)]"
|
fill
|
||||||
loading="lazy"
|
className="rounded object-cover border border-[var(--border-subtle)]"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
{user.roomId && user.roomName && (
|
{user.roomId && user.roomName && (
|
||||||
<div className="absolute -bottom-0.5 -right-0.5">
|
<div className="absolute -bottom-0.5 -right-0.5">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { register } from "@/actions/register";
|
import { register } from "@/actions/register";
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export function RegisterForm({
|
|||||||
<div className="absolute inset-0 rounded-full overflow-hidden" style={{ zIndex: -1 }}>
|
<div className="absolute inset-0 rounded-full overflow-hidden" style={{ zIndex: -1 }}>
|
||||||
<div className="w-full h-full" style={{ background: "black", filter: "blur(8px)", transform: "scale(1.2)", opacity: 0.6 }} />
|
<div className="w-full h-full" style={{ background: "black", filter: "blur(8px)", transform: "scale(1.2)", opacity: 0.6 }} />
|
||||||
</div>
|
</div>
|
||||||
<img src="/assets/images/FrankwithBag.gif" className="w-full h-full" style={{ objectFit: "contain", objectPosition: "center" }} />
|
<Image src="/assets/images/FrankwithBag.gif" alt="" fill className="!static" style={{ objectFit: "contain", objectPosition: "center", width: "100%", height: "100%" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,18 +204,6 @@ export function RegisterForm({
|
|||||||
{isPending ? t("creatingAccount") : t("createAccount")}
|
{isPending ? t("creatingAccount") : t("createAccount")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Social Login - optional */}
|
|
||||||
{false && (
|
|
||||||
<div className="pt-4 border-t" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }}>
|
|
||||||
<p className="text-center text-sm mb-3" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Or register with
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{/* Google, Discord, GitHub buttons would go here */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/login" className="text-sm font-semibold hover:underline" style={{ color: "var(--color-primary)" }}>
|
<Link href="/login" className="text-sm font-semibold hover:underline" style={{ color: "var(--color-primary)" }}>
|
||||||
{t("alreadyHaveAccount")}
|
{t("alreadyHaveAccount")}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -7,7 +8,7 @@ import { HomeLoginForm } from "@/components/auth/home-login-form";
|
|||||||
|
|
||||||
export default async function GuestView() {
|
export default async function GuestView() {
|
||||||
const t = await getTranslations("pages.home");
|
const t = await getTranslations("pages.home");
|
||||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
const _hotelName = await siteSettings.get("hotel_name", "Atom");
|
||||||
const imager =
|
const imager =
|
||||||
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
|
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export default async function GuestView() {
|
|||||||
|
|
||||||
<a href="/register" className="relative block">
|
<a href="/register" className="relative block">
|
||||||
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||||
<img src="/assets/images/EnterHubbly.png" alt="Register" className="block" />
|
<Image src="/assets/images/EnterHubbly.png" alt="Register" width={300} height={100} className="block" unoptimized />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white text-shadow"
|
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white text-shadow"
|
||||||
@@ -96,21 +97,20 @@ export default async function GuestView() {
|
|||||||
className="relative overflow-visible rounded-md p-0 shadow-lg"
|
className="relative overflow-visible rounded-md p-0 shadow-lg"
|
||||||
style={{ width: "50px", height: "50px", justifySelf: "center", backgroundColor: "var(--color-surface)" }}
|
style={{ width: "50px", height: "50px", justifySelf: "center", backgroundColor: "var(--color-surface)" }}
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
|
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
|
||||||
alt={u.username}
|
alt={u.username}
|
||||||
loading="lazy"
|
width={50}
|
||||||
decoding="async"
|
height={62}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "-12px",
|
top: "-12px",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
width: "auto",
|
|
||||||
height: "auto",
|
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
maxHeight: "none",
|
maxHeight: "none",
|
||||||
}}
|
}}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -150,11 +150,12 @@ export default async function GuestView() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
||||||
<img
|
<Image
|
||||||
src={a.image}
|
src={a.image}
|
||||||
alt={a.title}
|
alt={a.title}
|
||||||
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
fill
|
||||||
loading="lazy"
|
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 w-full p-2"
|
className="absolute left-0 w-full p-2"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -72,11 +73,14 @@ export default async function UserView({ userId, username, look }: UserViewProps
|
|||||||
/>
|
/>
|
||||||
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
|
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
|
||||||
<Link href={`/u/${username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
|
<Link href={`/u/${username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
|
||||||
<img
|
<Image
|
||||||
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
|
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
|
||||||
alt={username}
|
alt={username}
|
||||||
|
width={64}
|
||||||
|
height={110}
|
||||||
className="drop-shadow-2xl"
|
className="drop-shadow-2xl"
|
||||||
style={{ imageRendering: "pixelated" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
@@ -134,12 +138,14 @@ export default async function UserView({ userId, username, look }: UserViewProps
|
|||||||
href={`/u/${f.username}`}
|
href={`/u/${f.username}`}
|
||||||
className="transition-all duration-200 hover:scale-110"
|
className="transition-all duration-200 hover:scale-110"
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
||||||
alt={f.username}
|
alt={f.username}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
className="h-10 w-10"
|
className="h-10 w-10"
|
||||||
loading="lazy"
|
|
||||||
title={f.username}
|
title={f.username}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
@@ -168,11 +174,12 @@ export default async function UserView({ userId, username, look }: UserViewProps
|
|||||||
</div>
|
</div>
|
||||||
<Link href={`/news/${latestArticle.slug}`} className="group block">
|
<Link href={`/news/${latestArticle.slug}`} className="group block">
|
||||||
<div className="relative aspect-[16/9] overflow-hidden">
|
<div className="relative aspect-[16/9] overflow-hidden">
|
||||||
<img
|
<Image
|
||||||
src={latestArticle.image}
|
src={latestArticle.image}
|
||||||
alt={latestArticle.title}
|
alt={latestArticle.title}
|
||||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
fill
|
||||||
loading="lazy"
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { useLocale } from "next-intl";
|
import { useLocale } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTransition, useState, useRef, useEffect } from "react";
|
import { useTransition, useState, useRef, useEffect } from "react";
|
||||||
@@ -45,9 +46,11 @@ export function LanguageSwitcher() {
|
|||||||
className="nav-item flex items-center gap-1.5 bg-transparent text-[13px]"
|
className="nav-item flex items-center gap-1.5 bg-transparent text-[13px]"
|
||||||
style={{ border: "none", cursor: "pointer", padding: "0 0.25rem" }}
|
style={{ border: "none", cursor: "pointer", padding: "0 0.25rem" }}
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src={`/assets/images/icons/flags/${current.code}.png`}
|
src={`/assets/images/icons/flags/${current.code}.png`}
|
||||||
alt={current.lang}
|
alt={current.lang}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
className="inline-block h-4 w-auto"
|
className="inline-block h-4 w-auto"
|
||||||
/>
|
/>
|
||||||
<span>{current.code.toUpperCase()}</span>
|
<span>{current.code.toUpperCase()}</span>
|
||||||
@@ -85,9 +88,11 @@ export function LanguageSwitcher() {
|
|||||||
e.currentTarget.style.color = "var(--color-text)";
|
e.currentTarget.style.color = "var(--color-text)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src={`/assets/images/icons/flags/${l.code}.png`}
|
src={`/assets/images/icons/flags/${l.code}.png`}
|
||||||
alt={l.lang}
|
alt={l.lang}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
className="inline-block h-4 w-auto"
|
className="inline-block h-4 w-auto"
|
||||||
/>
|
/>
|
||||||
{l.label}
|
{l.label}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
interface NavDropdownProps {
|
interface NavDropdownProps {
|
||||||
@@ -39,7 +40,7 @@ export function NavDropdown({ label, icon, children }: NavDropdownProps) {
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
{icon ? <img src={icon} alt="" className="w-5 h-5 mr-1.5" /> : null}
|
{icon ? <Image src={icon} alt="" width={20} height={20} className="mr-1.5" unoptimized /> : null}
|
||||||
{label}
|
{label}
|
||||||
<svg className={`ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 ${open ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
<svg className={`ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 ${open ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -22,10 +22,16 @@ const COLORS = [
|
|||||||
|
|
||||||
export function NavbarColorPicker() {
|
export function NavbarColorPicker() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return 0;
|
||||||
|
const saved = localStorage.getItem("navbarColor");
|
||||||
|
if (!saved) return 0;
|
||||||
|
return Math.max(0, COLORS.findIndex((c) => c.value === saved));
|
||||||
|
});
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
function apply(index: number) {
|
function apply(index: number) {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- index validated by findIndex + Math.max, guard below
|
||||||
const c = COLORS[index];
|
const c = COLORS[index];
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
document.documentElement.style.setProperty("--color-navbar", c.value);
|
document.documentElement.style.setProperty("--color-navbar", c.value);
|
||||||
@@ -42,8 +48,6 @@ export function NavbarColorPicker() {
|
|||||||
const saved = localStorage.getItem("navbarColor");
|
const saved = localStorage.getItem("navbarColor");
|
||||||
const savedText = localStorage.getItem("navbarTextColor");
|
const savedText = localStorage.getItem("navbarTextColor");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const idx = COLORS.findIndex((c) => c.value === saved);
|
|
||||||
if (idx >= 0) setActive(idx);
|
|
||||||
document.documentElement.style.setProperty("--color-navbar", saved);
|
document.documentElement.style.setProperty("--color-navbar", saved);
|
||||||
}
|
}
|
||||||
if (savedText) {
|
if (savedText) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { MobileNav } from "@/components/mobile-nav";
|
import { MobileNav } from "@/components/mobile-nav";
|
||||||
@@ -19,14 +20,14 @@ export async function Navigation() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-x-1 md:gap-x-1 flex-1 md:flex-none">
|
<div className="flex items-center gap-x-1 md:gap-x-1 flex-1 md:flex-none">
|
||||||
<Link href="/" className="nav-item shrink-0">
|
<Link href="/" className="nav-item shrink-0">
|
||||||
<img src="/assets/images/icons/navigation/me.png" alt="" className="w-5 h-5 mr-1.5" />
|
<Image src="/assets/images/icons/navigation/me.png" alt="" width={20} height={20} className="mr-1.5" />
|
||||||
<span>{t("home")}</span>
|
<span>{t("home")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileNav menuLabel={t("openMenu") || "Open menu"} closeLabel={t("closeMenu") || "Close menu"}>
|
<MobileNav menuLabel={t("openMenu") || "Open menu"} closeLabel={t("closeMenu") || "Close menu"}>
|
||||||
<Link href="/" className="nav-item md:hidden" role="menuitem">
|
<Link href="/" className="nav-item md:hidden" role="menuitem">
|
||||||
<img src="/assets/images/icons/navigation/me.png" alt="" className="w-5 h-5 mr-1.5" />
|
<Image src="/assets/images/icons/navigation/me.png" alt="" width={20} height={20} className="mr-1.5" />
|
||||||
{t("home")}
|
{t("home")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -45,12 +46,12 @@ export async function Navigation() {
|
|||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
|
|
||||||
<Link href="/leaderboard" className="nav-item" role="menuitem">
|
<Link href="/leaderboard" className="nav-item" role="menuitem">
|
||||||
<img src="/assets/images/icons/navigation/leaderboards.png" alt="" className="w-5 h-5 mr-1.5" />
|
<Image src="/assets/images/icons/navigation/leaderboards.png" alt="" width={20} height={20} className="mr-1.5" />
|
||||||
{t("leaderboards")}
|
{t("leaderboards")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/radio" className="nav-item" role="menuitem">
|
<Link href="/radio" className="nav-item" role="menuitem">
|
||||||
<img src="/assets/images/icons/navigation/bb.png" alt="" className="w-5 h-5 mr-1.5" />
|
<Image src="/assets/images/icons/navigation/bb.png" alt="" width={20} height={20} className="mr-1.5" />
|
||||||
{t("radio")}
|
{t("radio")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -66,11 +67,11 @@ export async function Navigation() {
|
|||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/friends" className="nav-item">
|
<Link href="/friends" className="nav-item">
|
||||||
<img src="/assets/images/icons/navigation/goody.png" alt="" className="w-5 h-5 mr-1.5" />
|
<Image src="/assets/images/icons/navigation/goody.png" alt="" width={20} height={20} className="mr-1.5" />
|
||||||
{t("friends")}
|
{t("friends")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/messages" className="nav-item">
|
<Link href="/messages" className="nav-item">
|
||||||
<img src="/assets/images/icons/navigation/bb.png" alt="" className="w-5 h-5 mr-1.5" />
|
<Image src="/assets/images/icons/navigation/bb.png" alt="" width={20} height={20} className="mr-1.5" />
|
||||||
{t("messages")}
|
{t("messages")}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -78,7 +79,7 @@ export async function Navigation() {
|
|||||||
|
|
||||||
{isStaff ? (
|
{isStaff ? (
|
||||||
<Link href="/admin" className="nav-item">
|
<Link href="/admin" className="nav-item">
|
||||||
<img src="/assets/images/icons/navigation/home.png" alt="" className="w-5 h-5 mr-1.5" />
|
<Image src="/assets/images/icons/navigation/home.png" alt="" width={20} height={20} className="mr-1.5" />
|
||||||
{t("admin")}
|
{t("admin")}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ export default function LogoGenerator() {
|
|||||||
// Load font and render preview
|
// Load font and render preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setFontLoaded(false);
|
|
||||||
setFontError(false);
|
|
||||||
|
|
||||||
getFontInfo(styleName)
|
getFontInfo(styleName)
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
@@ -104,7 +102,8 @@ export default function LogoGenerator() {
|
|||||||
try {
|
try {
|
||||||
const JSZip = (await import("jszip")).default;
|
const JSZip = (await import("jszip")).default;
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const folder = zip.folder(safeText.replace(/[^a-z0-9]+/gi, "_") || "logo")!;
|
const folder = zip.folder(safeText.replace(/[^a-z0-9]+/gi, "_") || "logo");
|
||||||
|
if (!folder) throw new Error("Failed to create zip folder");
|
||||||
let fail = 0;
|
let fail = 0;
|
||||||
|
|
||||||
for (const font of allFonts) {
|
for (const font of allFonts) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type LightboxPhoto = {
|
|||||||
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||||
// Index of the photo shown in the lightbox, or null when closed.
|
// Index of the photo shown in the lightbox, or null when closed.
|
||||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
|
||||||
const active = openIndex !== null ? photos[openIndex] : null;
|
const active = openIndex !== null ? photos[openIndex] : null;
|
||||||
|
|
||||||
const close = useCallback(() => setOpenIndex(null), []);
|
const close = useCallback(() => setOpenIndex(null), []);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import RadioPlayer from "./radio-player";
|
|||||||
* /api/radio/config, which lets staff toggle the radio without a redeploy.
|
* /api/radio/config, which lets staff toggle the radio without a redeploy.
|
||||||
*/
|
*/
|
||||||
export default async function RadioPlayerGate() {
|
export default async function RadioPlayerGate() {
|
||||||
let enabled = false;
|
let enabled: boolean;
|
||||||
let streamUrl = "";
|
let streamUrl: string;
|
||||||
try {
|
try {
|
||||||
const [enabledRaw, urlRaw] = await Promise.all([
|
const [enabledRaw, urlRaw] = await Promise.all([
|
||||||
siteSettings.get("radio_enabled", "0"),
|
siteSettings.get("radio_enabled", "0"),
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ interface CharInfo {
|
|||||||
|
|
||||||
const fontCache = new Map<string, { chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }>();
|
const fontCache = new Map<string, { chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }>();
|
||||||
|
|
||||||
function hexPixel(data: Uint8ClampedArray, x: number, y: number, w: number): number {
|
|
||||||
const i = (y * w + x) * 4;
|
|
||||||
return (data[i] << 24) | (data[i + 1] << 16) | (data[i + 2] << 8) | data[i + 3];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTransparent(data: Uint8ClampedArray, x: number, y: number, w: number): boolean {
|
function isTransparent(data: Uint8ClampedArray, x: number, y: number, w: number): boolean {
|
||||||
return data[(y * w + x) * 4 + 3] === 0;
|
return data[(y * w + x) * 4 + 3] === 0;
|
||||||
}
|
}
|
||||||
@@ -41,6 +36,7 @@ function scanCharWidths(data: Uint8ClampedArray, sw: number, sh: number): CharIn
|
|||||||
|
|
||||||
const w = end - start;
|
const w = end - start;
|
||||||
if (w > 0) {
|
if (w > 0) {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
|
||||||
chars.push({ char: CHAR_ORDER[ci], x: start, w });
|
chars.push({ char: CHAR_ORDER[ci], x: start, w });
|
||||||
ci++;
|
ci++;
|
||||||
}
|
}
|
||||||
@@ -64,7 +60,8 @@ export async function getFontInfo(font: string): Promise<{ chars: CharInfo[]; h:
|
|||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = img.naturalWidth;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = img.naturalHeight;
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Could not get 2D context");
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -7,14 +8,14 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
|
|
||||||
export async function SiteHeader() {
|
export async function SiteHeader() {
|
||||||
const t = await getTranslations("header");
|
const t = await getTranslations("header");
|
||||||
const [session, hotelName, header, logo] = await Promise.all([
|
const [_session, hotelName, header, logo] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
siteSettings.get("hotel_name", "Atom"),
|
siteSettings.get("hotel_name", "Atom"),
|
||||||
siteSettings.get("cms_header", "/assets/images/background.png"),
|
siteSettings.get("cms_header", "/assets/images/background.png"),
|
||||||
siteSettings.get("cms_logo", ""),
|
siteSettings.get("cms_logo", ""),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let online = 0;
|
let online: number;
|
||||||
try {
|
try {
|
||||||
online = await cached("online_count", 10_000, () =>
|
online = await cached("online_count", 10_000, () =>
|
||||||
prisma.user.count({ where: { online: "1" } }),
|
prisma.user.count({ where: { online: "1" } }),
|
||||||
@@ -49,7 +50,7 @@ export async function SiteHeader() {
|
|||||||
<div className="relative flex flex-col items-center justify-center px-4 py-12 min-h-[18rem]">
|
<div className="relative flex flex-col items-center justify-center px-4 py-12 min-h-[18rem]">
|
||||||
<Link href="/" className="transition-transform duration-300 hover:scale-105 mb-8">
|
<Link href="/" className="transition-transform duration-300 hover:scale-105 mb-8">
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<img className="drop-shadow-2xl block" src={logo} alt={hotelName ?? "Hotel"} style={{ maxHeight: 120 }} />
|
<Image className="drop-shadow-2xl block" src={logo} alt={hotelName ?? "Hotel"} width={200} height={120} style={{ maxHeight: 120, width: "auto", height: "auto" }} unoptimized />
|
||||||
) : (
|
) : (
|
||||||
<h1
|
<h1
|
||||||
className="text-center font-extrabold tracking-tight leading-none text-white"
|
className="text-center font-extrabold tracking-tight leading-none text-white"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Light/dark toggle. The actual class lives on <html> (so `.app.dark` selectors
|
* Light/dark toggle. The actual class lives on <html> (so `.app.dark` selectors
|
||||||
@@ -9,11 +9,9 @@ import { useEffect, useState } from "react";
|
|||||||
* theme before paint to avoid a flash — this component only reflects/toggles it.
|
* theme before paint to avoid a flash — this component only reflects/toggles it.
|
||||||
*/
|
*/
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const [dark, setDark] = useState(false);
|
const [dark, setDark] = useState(() =>
|
||||||
|
typeof window !== "undefined" && document.documentElement.classList.contains("dark"),
|
||||||
useEffect(() => {
|
);
|
||||||
setDark(document.documentElement.classList.contains("dark"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
const next = !dark;
|
const next = !dark;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { auth, signOut } from "@/lib/auth";
|
import { auth, signOut } from "@/lib/auth";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -88,8 +89,7 @@ export async function TopHeader() {
|
|||||||
|
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary aria-label="User menu" className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden">
|
<summary aria-label="User menu" className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden">
|
||||||
{/* biome-ignore lint/performance/noImgElement: external imager */}
|
<Image className="bg-no-repeat bg-center" src={avatar} alt="" width={54} height={62} unoptimized />
|
||||||
<img className="w-[54px] h-[62px] bg-no-repeat bg-center" src={avatar} alt="" />
|
|
||||||
<span className="-ml-2 font-semibold" style={{ color: "var(--color-navbar-text, var(--color-text))" }}>{session.user.name}</span>
|
<span className="-ml-2 font-semibold" style={{ color: "var(--color-navbar-text, var(--color-text))" }}>{session.user.name}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default getRequestConfig(async () => {
|
|||||||
getMessageFallback({ key }: { key: string }) {
|
getMessageFallback({ key }: { key: string }) {
|
||||||
const fromEnglish = key
|
const fromEnglish = key
|
||||||
.split(".")
|
.split(".")
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- o guarded as object, k is i18n key segment
|
||||||
.reduce<unknown>((o, k) => (o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined), fallback);
|
.reduce<unknown>((o, k) => (o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined), fallback);
|
||||||
if (typeof fromEnglish === "string") return fromEnglish;
|
if (typeof fromEnglish === "string") return fromEnglish;
|
||||||
const seg = key.split(".").pop() ?? key;
|
const seg = key.split(".").pop() ?? key;
|
||||||
|
|||||||
+3
-1
@@ -19,7 +19,9 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
|||||||
|
|
||||||
// Try TOTP first
|
// Try TOTP first
|
||||||
try {
|
try {
|
||||||
const secret = new LaravelEncrypter(env.APP_KEY!).decrypt(user.twoFactorSecret);
|
const appKey = env.APP_KEY;
|
||||||
|
if (!appKey) throw new Error("APP_KEY not configured");
|
||||||
|
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
|
||||||
if (verifyTotp(code, secret)) return true;
|
if (verifyTotp(code, secret)) return true;
|
||||||
} catch { /* fall through to recovery */ }
|
} catch { /* fall through to recovery */ }
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function generateRequestId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldLog(level: LogLevel): boolean {
|
function shouldLog(level: LogLevel): boolean {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- LOG_LEVELS keyed by LogLevel union
|
||||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ function cleanup(): void {
|
|||||||
if (buckets.size > MAX_BUCKETS) {
|
if (buckets.size > MAX_BUCKETS) {
|
||||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||||
const toRemove = Math.floor(sorted.length * 0.2);
|
const toRemove = Math.floor(sorted.length * 0.2);
|
||||||
for (let i = 0; i < toRemove; i++) buckets.delete(sorted[i][0]);
|
for (let i = 0; i < toRemove; i++)
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- numeric array index
|
||||||
|
buckets.delete(sorted[i][0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +49,6 @@ export async function rateLimit(
|
|||||||
if (redis) {
|
if (redis) {
|
||||||
try {
|
try {
|
||||||
const windowKey = `ratelimit:${key}`;
|
const windowKey = `ratelimit:${key}`;
|
||||||
const windowSec = Math.ceil(windowMs / 1000);
|
|
||||||
const current = await redis.incr(windowKey);
|
const current = await redis.incr(windowKey);
|
||||||
if (current === 1) await redis.pexpire(windowKey, windowMs);
|
if (current === 1) await redis.pexpire(windowKey, windowMs);
|
||||||
const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey));
|
const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendMail } from "@/lib/services/email";
|
import { sendMail } from "@/lib/services/email";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
// === Alert service (AtomCMS → Next.js) ===========================================
|
// === Alert service (AtomCMS → Next.js) ===========================================
|
||||||
//
|
//
|
||||||
@@ -111,12 +112,12 @@ async function postDiscord(input: SendAlertInput): Promise<boolean> {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error("[alert] Discord webhook returned", res.status);
|
logger.error("Discord webhook returned non-OK status", { module: "alert", status: res.status });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[alert] Discord webhook failed:", (e as Error).message);
|
logger.error("Discord webhook failed", { module: "alert", error: (e as Error).message });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +153,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
return await sendMail(to, subject, html);
|
return await sendMail(to, subject, html);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[alert] staff email failed:", (e as Error).message);
|
logger.error("Staff email failed", { module: "alert", error: (e as Error).message });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,7 +190,7 @@ export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult>
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// DB unreachable / schema drift: keep the alert best-effort. We already
|
// DB unreachable / schema drift: keep the alert best-effort. We already
|
||||||
// notified Discord/email above, so the alert isn't lost.
|
// notified Discord/email above, so the alert isn't lost.
|
||||||
console.error("[alert] failed to persist alert_logs row:", (e as Error).message);
|
logger.error("Failed to persist alert_logs row", { module: "alert", error: (e as Error).message });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { logged, sentViaDiscord, sentViaEmail };
|
return { logged, sentViaDiscord, sentViaEmail };
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ const LABELS: Record<CurrencyType, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function currencyLabel(type: CurrencyType): string {
|
export function currencyLabel(type: CurrencyType): string {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyType enum
|
||||||
return LABELS[type];
|
return LABELS[type];
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { writeFile, mkdir } from "fs/promises";
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
import { join } from "path";
|
import { resolve } from "path";
|
||||||
import nodemailer, { type Transporter } from "nodemailer";
|
import nodemailer, { type Transporter } from "nodemailer";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
let transporter: Transporter | null = null;
|
let transporter: Transporter | null = null;
|
||||||
let resend: Resend | null = null;
|
let resend: Resend | null = null;
|
||||||
@@ -39,7 +40,7 @@ function sendViaSendmail(to: string, subject: string, html: string, from: string
|
|||||||
|
|
||||||
const child = exec("sendmail -t", (error) => {
|
const child = exec("sendmail -t", (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("[email] sendmail failed:", error.message);
|
logger.error("Sendmail failed", { module: "email", error: error.message });
|
||||||
resolve(false);
|
resolve(false);
|
||||||
} else {
|
} else {
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@@ -55,16 +56,18 @@ function sendViaSendmail(to: string, subject: string, html: string, from: string
|
|||||||
|
|
||||||
async function writeToFile(to: string, subject: string, html: string, from: string): Promise<boolean> {
|
async function writeToFile(to: string, subject: string, html: string, from: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const logDir = join(process.cwd(), "storage", "logs");
|
const logDir = resolve(process.cwd(), "storage", "logs");
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
await mkdir(logDir, { recursive: true });
|
await mkdir(logDir, { recursive: true });
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const filename = `email-${timestamp}.html`;
|
const filename = `email-${timestamp}.html`;
|
||||||
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${html}`;
|
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${html}`;
|
||||||
await writeFile(join(logDir, filename), content, "utf-8");
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
console.log(`[email] Written to storage/logs/${filename}`);
|
await writeFile(resolve(logDir, filename), content, "utf-8");
|
||||||
|
logger.info("Email written to file", { module: "email", filename });
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[email] Failed to write email to file:", (e as Error).message);
|
logger.error("Failed to write email to file", { module: "email", error: (e as Error).message });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +82,7 @@ export async function sendMail(to: string, subject: string, html: string): Promi
|
|||||||
await r.emails.send({ from, to, subject, html });
|
await r.emails.send({ from, to, subject, html });
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[email] Resend failed:", (e as Error).message);
|
logger.error("Resend API failed", { module: "email", error: (e as Error).message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ export async function sendMail(to: string, subject: string, html: string): Promi
|
|||||||
await t.sendMail({ from, to, subject, html });
|
await t.sendMail({ from, to, subject, html });
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[email] SMTP failed:", (e as Error).message);
|
logger.error("SMTP failed", { module: "email", error: (e as Error).message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export async function checkVpn(ip: string): Promise<IpVerdict> {
|
|||||||
const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
|
const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
const d = (await res.json()) as Record<string, { proxy?: string; type?: string }>;
|
const d = (await res.json()) as Record<string, { proxy?: string; type?: string }>;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- ip is the API response key from proxycheck
|
||||||
const entry = d?.[ip];
|
const entry = d?.[ip];
|
||||||
if (entry?.proxy === "yes") return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
|
if (entry?.proxy === "yes") return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
|
||||||
return { blocked: false };
|
return { blocked: false };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { CurrencyType } from "@/lib/services/currency";
|
import { CurrencyType } from "@/lib/services/currency";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export interface RconPayload {
|
export interface RconPayload {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -47,7 +48,7 @@ export function tcpTransport(opts: {
|
|||||||
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.error("[RCON] delivery failed after retries:", payload.key);
|
logger.error("RCON delivery failed after retries", { module: "rcon", key: payload.key });
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export async function sendCurrency(
|
|||||||
data: { credits: { increment: amount } },
|
data: { credits: { increment: amount } },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyName union, not "credits"
|
||||||
const t = TYPE_VALUE[type];
|
const t = TYPE_VALUE[type];
|
||||||
await deps.db.usersCurrency.upsert({
|
await deps.db.usersCurrency.upsert({
|
||||||
where: { userId_type: { userId, type: t } },
|
where: { userId_type: { userId, type: t } },
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class SiteSettings {
|
|||||||
async get(key: string, fallback: string | null = null): Promise<string | null> {
|
async get(key: string, fallback: string | null = null): Promise<string | null> {
|
||||||
const map = await this.load();
|
const map = await this.load();
|
||||||
if (map.has(key)) return map.get(key) as string;
|
if (map.has(key)) return map.get(key) as string;
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- guarded by `key in DEFAULTS`
|
||||||
if (key in DEFAULTS) return DEFAULTS[key] as string;
|
if (key in DEFAULTS) return DEFAULTS[key] as string;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
[2026-07-02 19:17:01] [OK] Dependencies installed
|
|
||||||
[2026-07-02 19:17:21] [OK] Build successful
|
|
||||||
[2026-07-02 19:17:21] [INFO] Restarting web service...
|
|
||||||
[2026-07-02 19:17:37] [OK] Backup created: db_backup_20260702_191736.sql
|
|
||||||
[2026-07-02 19:17:52] [OK] Emulator built
|
|
||||||
[2026-07-02 19:17:52] [INFO] Restarting emulator service...
|
|
||||||
[2026-07-02 19:18:46] [OK] Dependencies installed
|
|
||||||
[2026-07-02 19:18:49] [OK] Build successful
|
|
||||||
[2026-07-02 19:19:07] [OK] Dependencies installed
|
|
||||||
Reference in new issue
Block a user