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/
|
||||
prod.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",
|
||||
"prisma:generate": "prisma generate",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings 200",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
@@ -39,6 +41,8 @@
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@next/eslint-plugin-next": "^16.2.10",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
@@ -48,11 +52,18 @@
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"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",
|
||||
"prettier": "^3.9.5",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tsx": "^4.22.5",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"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 { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -21,9 +20,9 @@ function getDbConfig(): { url: string; database: string } {
|
||||
}
|
||||
|
||||
async function ensureConnection(): Promise<void> {
|
||||
const { database } = getDbConfig();
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
await conn.execute(
|
||||
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
|
||||
@@ -38,8 +37,9 @@ async function ensureConnection(): Promise<void> {
|
||||
}
|
||||
|
||||
async function getApplied(): Promise<Set<string>> {
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
||||
@@ -66,8 +66,9 @@ function loadMigrations(): MigrationFile[] {
|
||||
}
|
||||
|
||||
async function apply(migration: MigrationFile): Promise<void> {
|
||||
const { url } = getDbConfig();
|
||||
const mysql = await import("mysql2/promise");
|
||||
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
const statements = migration.sql
|
||||
.split(";")
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
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 {
|
||||
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);
|
||||
} catch {
|
||||
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",
|
||||
targetId: Number(entry.id),
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Unique name collision or DB error — re-render unchanged with error.
|
||||
revalidatePath("/admin/help-questions");
|
||||
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({
|
||||
where: { key },
|
||||
update: { value },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
create: { key, value, comment: COMMENTS[key] ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
+17
-11
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
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 (!ALLOWED.includes(file.type)) throw new Error("Invalid file type");
|
||||
|
||||
const dir = path.join(process.cwd(), MEDIA_DIR);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
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("/admin/media");
|
||||
@@ -32,10 +35,9 @@ export async function uploadMedia(formData: FormData): Promise<void> {
|
||||
export async function deleteMedia(name: string): Promise<void> {
|
||||
await requireStaff();
|
||||
const { unlink } = await import("fs/promises");
|
||||
const dir = path.join(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.join(dir, name);
|
||||
// Prevent path traversal
|
||||
if (name.includes("..") || name.includes("/")) return;
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
@@ -52,13 +54,17 @@ export async function uploadMediaAndReturn(formData: FormData): Promise<string>
|
||||
if (file.size > MAX_SIZE) return "";
|
||||
if (!ALLOWED.includes(file.type)) return "";
|
||||
|
||||
const dir = path.join(process.cwd(), MEDIA_DIR);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
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("/admin/media");
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
POINTS_KEYS.map((key) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
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" },
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
||||
export async function applyPreset(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("preset") ?? "");
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect("/admin/theme");
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function postComment(formData: FormData): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
let slug: string | null = null;
|
||||
let slug: string | null;
|
||||
try {
|
||||
// Confirm the article exists (and grab its slug for revalidation).
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
let slug: string | null = null;
|
||||
let slug: string | null;
|
||||
try {
|
||||
// Confirm the article exists (and grab its slug for revalidation).
|
||||
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";
|
||||
|
||||
let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null;
|
||||
let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: u },
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { CurrencyType } from "@/lib/services/currency";
|
||||
|
||||
const PATH = "/admin/commandocentrum";
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
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();
|
||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||
|
||||
let outcome: "bought" | "invalid" | "credits" | "fail" = "fail";
|
||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
||||
let boughtCode = "";
|
||||
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const ip = await clientIp();
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
|
||||
@@ -2,33 +2,32 @@
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export async function linkDiscordId(discordId: string): Promise<string | null> {
|
||||
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);
|
||||
if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) {
|
||||
return "Ongeldig Discord ID";
|
||||
return "Invalid Discord ID format";
|
||||
}
|
||||
|
||||
const discordIdClean = discordId.trim();
|
||||
|
||||
// Check if this Discord ID is already linked to another account.
|
||||
try {
|
||||
const existing = await prisma.socialAccounts.findUnique({
|
||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||
select: { userId: true },
|
||||
});
|
||||
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 {
|
||||
return "Fout bij controleren Discord ID";
|
||||
return "Failed to check Discord ID";
|
||||
}
|
||||
|
||||
try {
|
||||
// Upsert: create or update the social_accounts entry.
|
||||
await prisma.socialAccounts.upsert({
|
||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||
create: {
|
||||
@@ -41,15 +40,14 @@ export async function linkDiscordId(discordId: string): Promise<string | null> {
|
||||
update: { userId: BigInt(userId), updatedAt: new Date() },
|
||||
});
|
||||
|
||||
// Mark user as verified.
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mailVerified: "1" },
|
||||
});
|
||||
|
||||
return null; // success
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("[link-discord] Failed:", (e as Error).message);
|
||||
return "Fout bij koppelen van Discord account";
|
||||
logger.error("Failed to link Discord account", { module: "link-discord", error: (e as Error).message });
|
||||
return "Failed to link Discord account";
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export async function postShout(formData: FormData): Promise<void> {
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const ip = await clientIp();
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
|
||||
+23
-10
@@ -23,11 +23,16 @@ export async function saveFavicon(formData: FormData): Promise<{ success: boolea
|
||||
};
|
||||
const ext = mimeExt[file.type] ?? "png";
|
||||
const filename = `favicon-${Date.now()}.${ext}`;
|
||||
const dir = path.join(process.cwd(), FAVICON_DIR);
|
||||
const filePath = path.join(dir, filename);
|
||||
const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
|
||||
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());
|
||||
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);
|
||||
|
||||
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/")) {
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
try {
|
||||
await unlink(path.join(dir, oldName));
|
||||
} catch { /* ignore if file doesn't exist */ }
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
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 {
|
||||
const oldUrl = await siteSettings.get("cms_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/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
try {
|
||||
await unlink(path.join(dir, oldName));
|
||||
} catch { /* ignore */ }
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
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;
|
||||
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 = mimeExt[file.type] ?? "png";
|
||||
const ext = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const dir = path.join(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.join(dir, filename);
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
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());
|
||||
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);
|
||||
|
||||
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 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 = mimeExt[contentType] ?? "gif";
|
||||
const ext = contentType === "image/png" ? "png" : contentType === "image/gif" ? "gif" : contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "gif";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const dir = path.join(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.join(dir, filename);
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
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);
|
||||
|
||||
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 { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { env } from "@/env";
|
||||
|
||||
async function sessionUserId(): Promise<number> {
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function redeem(
|
||||
maxUses: number;
|
||||
useCount: number;
|
||||
expiresAt: Date | null;
|
||||
} | null = null;
|
||||
} | null;
|
||||
try {
|
||||
voucher = await prisma.websiteShopVouchers.findUnique({
|
||||
where: { code },
|
||||
|
||||
@@ -18,7 +18,7 @@ type Achievement = {
|
||||
export default async function AdminAchievements() {
|
||||
const t = await getTranslations("pages.admin.achievements");
|
||||
|
||||
let achievements: Achievement[] = [];
|
||||
let achievements: Achievement[];
|
||||
try {
|
||||
achievements = await prisma.achievements.findMany({
|
||||
select: {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminAds() {
|
||||
const t = await getTranslations("pages.admin.ads");
|
||||
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = [];
|
||||
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>>;
|
||||
try {
|
||||
ads = await prisma.websiteAds.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
|
||||
@@ -56,7 +56,7 @@ function Badge({ label, color }: { label: string; color: string }) {
|
||||
export default async function AdminAlerts() {
|
||||
const t = await getTranslations("pages.admin.alerts");
|
||||
|
||||
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>> = [];
|
||||
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
|
||||
try {
|
||||
alerts = await prisma.alertLogs.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
|
||||
@@ -21,7 +21,7 @@ function formatDate(d: Date | null): string {
|
||||
|
||||
export default async function AdminApplications() {
|
||||
const t = await getTranslations("pages.admin.applications");
|
||||
let applications: ApplicationRow[] = [];
|
||||
let applications: ApplicationRow[];
|
||||
try {
|
||||
applications = await prisma.websiteStaffApplications.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
|
||||
@@ -16,7 +16,7 @@ export default async function AdminBadges({
|
||||
const t = await getTranslations("pages.admin.badges");
|
||||
const { uploaded, error } = await searchParams;
|
||||
|
||||
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>> = [];
|
||||
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>>;
|
||||
try {
|
||||
badges = await prisma.websiteBadges.findMany({
|
||||
orderBy: { badgeName: "asc" },
|
||||
|
||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { saveFavicon, deleteFavicon } from "@/actions/save-favicon";
|
||||
import { FaviconGenerator } from "./favicon-generator";
|
||||
|
||||
@@ -70,10 +71,13 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
|
||||
{t("current")}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
<Image
|
||||
src={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>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deletePermission, upsertPermission } from "@/actions/admin-housekeeping";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -90,9 +91,6 @@ function getNavGroups(t: (key: string) => string) {
|
||||
|
||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const staff = await requireStaff();
|
||||
const t = await getTranslations("pages.admin.nav");
|
||||
const navGroups = getNavGroups(t);
|
||||
|
||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||
const u = await prisma.user
|
||||
.findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } })
|
||||
@@ -147,13 +145,13 @@ async function Sidebar({ staff }: { staff: { id: number; username: string; rank:
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-3 border-t border-white/[0.06]">
|
||||
<a
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>{t("backToSite")}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { createTrack, deleteTrack, toggleTrack } from '@/actions/admin-radio-autodj';
|
||||
import { StatusCard } from '@/components/admin/dashboard';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
@@ -220,12 +221,13 @@ export default async function AdminRadioAutoDjPage() {
|
||||
<td>
|
||||
<span className="inline-flex gap-2 items-center">
|
||||
{track.artworkUrl ? (
|
||||
<img
|
||||
<Image
|
||||
src={track.artworkUrl}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<strong>{track.title}</strong>
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from '@/actions/admin-radio-extra';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
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";
|
||||
|
||||
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="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>
|
||||
<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>
|
||||
|
||||
{b.imagePath ? (
|
||||
<img
|
||||
<NextImage
|
||||
src={b.imagePath}
|
||||
alt={b.title ?? t("banners.bannerAlt")}
|
||||
width={800}
|
||||
height={200}
|
||||
className="article-img my-2"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function AdminRadioEmbedPage() {
|
||||
{t("embedPage.livePreviewText")}
|
||||
</p>
|
||||
<div className="admin-card">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
{ }
|
||||
<audio controls preload="none" src={streamUrl} style={{ width: '100%' }}>
|
||||
{t("embedPage.audioUnsupported")}
|
||||
</audio>
|
||||
|
||||
@@ -65,6 +65,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
}
|
||||
|
||||
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];
|
||||
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 (!isRecord(value)) return null;
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createSetting, deleteSetting, updateSetting } from "@/actions/admin-settings";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -146,6 +146,7 @@ export default async function AdminTheme({
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
// eslint-disable-next-line security/detect-object-injection -- k from hardcoded array
|
||||
background: palette[k],
|
||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
||||
marginLeft: -3,
|
||||
|
||||
@@ -5,11 +5,11 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { CurrencyType } from "@/lib/services/currency";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const giveCurrency = async ({
|
||||
rconClient,
|
||||
db,
|
||||
rconClient: _rconClient,
|
||||
db: _db,
|
||||
userId,
|
||||
type,
|
||||
amount,
|
||||
@@ -158,7 +158,7 @@ export async function POST(request: Request) {
|
||||
{ status: 400 }
|
||||
);
|
||||
} 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(
|
||||
{ success: false, message: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiJson } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -314,6 +315,7 @@ export async function GET(req: Request) {
|
||||
]);
|
||||
|
||||
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]]),
|
||||
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
|
||||
|
||||
@@ -324,7 +326,7 @@ export async function GET(req: Request) {
|
||||
leaderboards: { totalBadges, achievementLevel, rarity },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Badge leaderboard error:", err);
|
||||
logger.error("Badge leaderboard error", { module: "badges/leaderboard", error: String(err) });
|
||||
return apiJson({
|
||||
viewerUserId: 0,
|
||||
badgeStats: [],
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function GET(req: Request) {
|
||||
const rows =
|
||||
type === "credits"
|
||||
? await loadCreditsRows()
|
||||
// eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
||||
: await loadCurrencyRows(CURRENCY_TYPE[type]);
|
||||
|
||||
return apiJson({ type, data: rows }, { status: 200 });
|
||||
|
||||
@@ -23,11 +23,17 @@ export async function GET(
|
||||
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)) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const bytes = await readFile(filePath);
|
||||
const mime: Record<string, string> = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
@@ -36,6 +42,7 @@ export async function GET(
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
headers: {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
|
||||
@@ -7,10 +7,12 @@ export const dynamic = "force-dynamic";
|
||||
const MEDIA_DIR = "assets/images/media";
|
||||
|
||||
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)) {
|
||||
return NextResponse.json({ files: [] });
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
||||
.map((f) => ({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -78,7 +79,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
try {
|
||||
result = await captureOrder(orderId);
|
||||
} 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(
|
||||
{ error: "Could not capture the PayPal payment. If you were charged, contact staff." },
|
||||
{ status: 502 },
|
||||
@@ -127,7 +128,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
},
|
||||
});
|
||||
} 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(
|
||||
{ error: "Payment captured but could not be recorded. Contact staff with your order id." },
|
||||
{ status: 500 },
|
||||
@@ -138,7 +139,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
} 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(
|
||||
{
|
||||
ok: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PAYPAL_CURRENCY,
|
||||
} from "@/lib/services/paypal";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -75,7 +76,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
credits,
|
||||
});
|
||||
} 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(
|
||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||
{ status: 502 },
|
||||
|
||||
@@ -25,6 +25,7 @@ function findCount(value: unknown, depth = 0): number | null {
|
||||
|
||||
const keys = ["current", "total", "num_listeners", "listeners", "unique_listeners", "count"];
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return 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 }: {
|
||||
ticket: string; clientUrl: string; hotelName: string; initialOnline: number;
|
||||
}) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [_isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [onlineCount, setOnlineCount] = useState(initialOnline);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,6 +29,12 @@ const ERROR_NOTE: Record<string, string> = {
|
||||
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({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -104,7 +110,7 @@ export default async function DrawBadgePage({
|
||||
role="alert"
|
||||
style={{ margin: "1rem 0 0", fontWeight: 700, color: "var(--color-danger)" }}
|
||||
>
|
||||
{ERROR_NOTE[error] ?? ERROR_NOTE.fail}
|
||||
{getErrorNote(error)}
|
||||
</p>
|
||||
) : null}
|
||||
</ContentCard>
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
@@ -38,9 +39,9 @@ export default function Error({
|
||||
<button type="button" className="btn btn-primary" onClick={() => reset()}>
|
||||
Try again
|
||||
</button>
|
||||
<a className="btn btn-outline" href="/">
|
||||
<Link className="btn btn-outline" href="/">
|
||||
Back home
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{error.digest ? (
|
||||
<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 sinceById = new Map(memberRows.map((m) => [m.userId, m.memberSince]));
|
||||
|
||||
const guildOwnerUserId = guild.userId;
|
||||
|
||||
const members: MemberView[] = memberIds
|
||||
.map((uid) => {
|
||||
const u = usersById.get(uid);
|
||||
@@ -108,7 +110,7 @@ export default async function GuildPage({
|
||||
look: u.look,
|
||||
motto: u.motto,
|
||||
memberSince: sinceById.get(uid) ?? 0,
|
||||
isOwner: uid === guild!.userId,
|
||||
isOwner: uid === guildOwnerUserId,
|
||||
};
|
||||
})
|
||||
.filter((m): m is MemberView => m !== null)
|
||||
|
||||
@@ -54,7 +54,7 @@ export default async function HelpCategoryPage({
|
||||
let cat: HelpCategory | null = null;
|
||||
try {
|
||||
cat = await prisma.websiteHelpCenterCategories.findUnique({
|
||||
where: { id: categoryId! },
|
||||
where: { id: categoryId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -81,7 +81,7 @@ export default async function HelpCategoryPage({
|
||||
let siblings: HelpCategoryLink[] = [];
|
||||
try {
|
||||
siblings = await prisma.websiteHelpCenterCategories.findMany({
|
||||
where: { id: { not: categoryId! } },
|
||||
where: { id: { not: categoryId } },
|
||||
orderBy: { position: "asc" },
|
||||
select: { id: true, name: true, position: true },
|
||||
take: 50,
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import { Nunito, Pixelify_Sans } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigation } from "@/components/navigation";
|
||||
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}`}>
|
||||
<head>
|
||||
<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="dns-prefetch" href="https://www.habbo.com" />
|
||||
{nitroUrl && nitroUrl.startsWith("http") ? (
|
||||
|
||||
@@ -101,6 +101,7 @@ async function loadSettingsRows(
|
||||
.map((t) => {
|
||||
const u = byId.get(t.userId as number);
|
||||
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) };
|
||||
})
|
||||
.filter((r): r is Row => r !== null);
|
||||
@@ -123,13 +124,14 @@ export default async function LeaderboardPage({
|
||||
const t = await getTranslations("pages.leaderboard");
|
||||
const { type } = await searchParams;
|
||||
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([
|
||||
active === "credits"
|
||||
? loadCreditsRows()
|
||||
: 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]),
|
||||
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.",
|
||||
};
|
||||
|
||||
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 }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
@@ -147,7 +154,7 @@ export default async function MePage({ searchParams }: { searchParams: SearchPar
|
||||
) : null}
|
||||
{error ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error}
|
||||
{getErrorMessage(error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export default async function ArticlePage({
|
||||
|
||||
const session = await auth();
|
||||
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
|
||||
// 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 Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -53,11 +54,15 @@ export default async function NewsPage() {
|
||||
}}
|
||||
>
|
||||
{a.image ? (
|
||||
<img
|
||||
src={a.image}
|
||||
alt=""
|
||||
style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover" }}
|
||||
/>
|
||||
<div style={{ position: "relative", width: "100%", aspectRatio: "16/9" }}>
|
||||
<Image
|
||||
src={a.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
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 }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const t = await getTranslations("pages.radioContests");
|
||||
await getTranslations("pages.radioContests");
|
||||
|
||||
let contestId: bigint;
|
||||
try {
|
||||
@@ -26,7 +26,7 @@ export default async function RadioContestDetailPage({
|
||||
}
|
||||
|
||||
const contest = await prisma.radioContests
|
||||
.findUnique({ where: { id: contestId! } })
|
||||
.findUnique({ where: { id: contestId } })
|
||||
.catch(() => null);
|
||||
|
||||
if (!contest) notFound();
|
||||
|
||||
@@ -16,7 +16,7 @@ export default async function RadioGiveawayDetailPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const t = await getTranslations("pages.radioGiveaways");
|
||||
await getTranslations("pages.radioGiveaways");
|
||||
|
||||
let giveawayId: bigint;
|
||||
try {
|
||||
@@ -26,7 +26,7 @@ export default async function RadioGiveawayDetailPage({
|
||||
}
|
||||
|
||||
const giveaway = await prisma.radioGiveaways
|
||||
.findUnique({ where: { id: giveawayId! } })
|
||||
.findUnique({ where: { id: giveawayId } })
|
||||
.catch(() => null);
|
||||
|
||||
if (!giveaway) notFound();
|
||||
|
||||
@@ -10,8 +10,6 @@ const RADIO_SIDEBAR_LINKS = [
|
||||
{ key: "leaderboard", href: "/radio/leaderboard", icon: "🏆" },
|
||||
] as const;
|
||||
|
||||
const SIDEBAR_KEYS = RADIO_SIDEBAR_LINKS.map((l) => l.key);
|
||||
|
||||
export default async function RadioLayout({ children }: { children: ReactNode }) {
|
||||
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", " ") : "";
|
||||
}
|
||||
|
||||
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() {
|
||||
const t = await getTranslations("pages.radioRequests");
|
||||
const genericT = await getTranslations("pages.radio");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ContentCard, EmptyState, OnlineBadge, RankBadge } from "@/components/public/ui";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -53,13 +54,13 @@ export default async function RankingsPage() {
|
||||
style={{ display: "flex", gap: "0.9rem", alignItems: "center" }}
|
||||
>
|
||||
<RankBadge position={i + 1} />
|
||||
{/* biome-ignore lint/performance/noImgElement: external avatar imager */}
|
||||
<img
|
||||
<Image
|
||||
className="avatar"
|
||||
src={avatar}
|
||||
alt={`${u.username} avatar`}
|
||||
width={50}
|
||||
height={90}
|
||||
unoptimized
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<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;
|
||||
try {
|
||||
cat = await prisma.websiteRareValueCategories.findUnique({
|
||||
where: { id: categoryId! },
|
||||
where: { id: categoryId },
|
||||
select: { id: true, name: true, badge: true },
|
||||
});
|
||||
} catch {
|
||||
@@ -65,7 +65,7 @@ export default async function RareCategoryPage({
|
||||
let rares: RareRow[] = [];
|
||||
try {
|
||||
rares = await prisma.websiteRareValues.findMany({
|
||||
where: { categoryId: categoryId! },
|
||||
where: { categoryId: categoryId },
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ const STATE_LABELS: Record<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: "🚪" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface OnlineUser {
|
||||
@@ -18,7 +19,7 @@ export function StatusCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
state = "neutral",
|
||||
state: _state = "neutral",
|
||||
icon,
|
||||
}: {
|
||||
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"
|
||||
>
|
||||
<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)}`}
|
||||
alt={user.username}
|
||||
className="w-full h-full rounded object-cover border border-[var(--border-subtle)]"
|
||||
loading="lazy"
|
||||
fill
|
||||
className="rounded object-cover border border-[var(--border-subtle)]"
|
||||
unoptimized
|
||||
/>
|
||||
{user.roomId && user.roomName && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
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="w-full h-full" style={{ background: "black", filter: "blur(8px)", transform: "scale(1.2)", opacity: 0.6 }} />
|
||||
</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>
|
||||
@@ -203,18 +204,6 @@ export function RegisterForm({
|
||||
{isPending ? t("creatingAccount") : t("createAccount")}
|
||||
</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">
|
||||
<Link href="/login" className="text-sm font-semibold hover:underline" style={{ color: "var(--color-primary)" }}>
|
||||
{t("alreadyHaveAccount")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -7,7 +8,7 @@ import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
|
||||
export default async function GuestView() {
|
||||
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 =
|
||||
(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">
|
||||
<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>
|
||||
<span
|
||||
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"
|
||||
style={{ width: "50px", height: "50px", justifySelf: "center", backgroundColor: "var(--color-surface)" }}
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
|
||||
alt={u.username}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={50}
|
||||
height={62}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-12px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
maxWidth: "none",
|
||||
maxHeight: "none",
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -150,11 +150,12 @@ export default async function GuestView() {
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
<Image
|
||||
src={a.image}
|
||||
alt={a.title}
|
||||
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
fill
|
||||
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 w-full p-2"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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">
|
||||
<Link href={`/u/${username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
|
||||
<img
|
||||
<Image
|
||||
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
|
||||
alt={username}
|
||||
width={64}
|
||||
height={110}
|
||||
className="drop-shadow-2xl"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex items-center mb-4">
|
||||
@@ -134,12 +138,14 @@ export default async function UserView({ userId, username, look }: UserViewProps
|
||||
href={`/u/${f.username}`}
|
||||
className="transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
||||
alt={f.username}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10"
|
||||
loading="lazy"
|
||||
title={f.username}
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
))
|
||||
@@ -168,11 +174,12 @@ export default async function UserView({ userId, username, look }: UserViewProps
|
||||
</div>
|
||||
<Link href={`/news/${latestArticle.slug}`} className="group block">
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
<Image
|
||||
src={latestArticle.image}
|
||||
alt={latestArticle.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
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]"
|
||||
style={{ border: "none", cursor: "pointer", padding: "0 0.25rem" }}
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={`/assets/images/icons/flags/${current.code}.png`}
|
||||
alt={current.lang}
|
||||
width={16}
|
||||
height={16}
|
||||
className="inline-block h-4 w-auto"
|
||||
/>
|
||||
<span>{current.code.toUpperCase()}</span>
|
||||
@@ -85,9 +88,11 @@ export function LanguageSwitcher() {
|
||||
e.currentTarget.style.color = "var(--color-text)";
|
||||
}}
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={`/assets/images/icons/flags/${l.code}.png`}
|
||||
alt={l.lang}
|
||||
width={16}
|
||||
height={16}
|
||||
className="inline-block h-4 w-auto"
|
||||
/>
|
||||
{l.label}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
|
||||
interface NavDropdownProps {
|
||||
@@ -39,7 +40,7 @@ export function NavDropdown({ label, icon, children }: NavDropdownProps) {
|
||||
aria-expanded={open}
|
||||
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}
|
||||
<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>
|
||||
|
||||
@@ -22,10 +22,16 @@ const COLORS = [
|
||||
|
||||
export function NavbarColorPicker() {
|
||||
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);
|
||||
|
||||
function apply(index: number) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- index validated by findIndex + Math.max, guard below
|
||||
const c = COLORS[index];
|
||||
if (!c) return;
|
||||
document.documentElement.style.setProperty("--color-navbar", c.value);
|
||||
@@ -42,8 +48,6 @@ export function NavbarColorPicker() {
|
||||
const saved = localStorage.getItem("navbarColor");
|
||||
const savedText = localStorage.getItem("navbarTextColor");
|
||||
if (saved) {
|
||||
const idx = COLORS.findIndex((c) => c.value === saved);
|
||||
if (idx >= 0) setActive(idx);
|
||||
document.documentElement.style.setProperty("--color-navbar", saved);
|
||||
}
|
||||
if (savedText) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
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">
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<MobileNav menuLabel={t("openMenu") || "Open menu"} closeLabel={t("closeMenu") || "Close menu"}>
|
||||
<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")}
|
||||
</Link>
|
||||
|
||||
@@ -45,12 +46,12 @@ export async function Navigation() {
|
||||
</NavDropdown>
|
||||
|
||||
<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")}
|
||||
</Link>
|
||||
|
||||
<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")}
|
||||
</Link>
|
||||
|
||||
@@ -66,11 +67,11 @@ export async function Navigation() {
|
||||
{session?.user ? (
|
||||
<>
|
||||
<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")}
|
||||
</Link>
|
||||
<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")}
|
||||
</Link>
|
||||
</>
|
||||
@@ -78,7 +79,7 @@ export async function Navigation() {
|
||||
|
||||
{isStaff ? (
|
||||
<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")}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
@@ -42,8 +42,6 @@ export default function LogoGenerator() {
|
||||
// Load font and render preview
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFontLoaded(false);
|
||||
setFontError(false);
|
||||
|
||||
getFontInfo(styleName)
|
||||
.then((info) => {
|
||||
@@ -104,7 +102,8 @@ export default function LogoGenerator() {
|
||||
try {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
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;
|
||||
|
||||
for (const font of allFonts) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type LightboxPhoto = {
|
||||
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
// Index of the photo shown in the lightbox, or null when closed.
|
||||
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 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.
|
||||
*/
|
||||
export default async function RadioPlayerGate() {
|
||||
let enabled = false;
|
||||
let streamUrl = "";
|
||||
let enabled: boolean;
|
||||
let streamUrl: string;
|
||||
try {
|
||||
const [enabledRaw, urlRaw] = await Promise.all([
|
||||
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 }>();
|
||||
|
||||
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 {
|
||||
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;
|
||||
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 });
|
||||
ci++;
|
||||
}
|
||||
@@ -64,7 +60,8 @@ export async function getFontInfo(font: string): Promise<{ chars: CharInfo[]; h:
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
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);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -7,14 +8,14 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function SiteHeader() {
|
||||
const t = await getTranslations("header");
|
||||
const [session, hotelName, header, logo] = await Promise.all([
|
||||
const [_session, hotelName, header, logo] = await Promise.all([
|
||||
auth(),
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
siteSettings.get("cms_header", "/assets/images/background.png"),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
]);
|
||||
|
||||
let online = 0;
|
||||
let online: number;
|
||||
try {
|
||||
online = await cached("online_count", 10_000, () =>
|
||||
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]">
|
||||
<Link href="/" className="transition-transform duration-300 hover:scale-105 mb-8">
|
||||
{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
|
||||
className="text-center font-extrabold tracking-tight leading-none text-white"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ThemeSwitcher() {
|
||||
const [dark, setDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains("dark"));
|
||||
}, []);
|
||||
const [dark, setDark] = useState(() =>
|
||||
typeof window !== "undefined" && document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
const next = !dark;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -88,8 +89,7 @@ export async function TopHeader() {
|
||||
|
||||
<details className="relative">
|
||||
<summary aria-label="User menu" className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden">
|
||||
{/* biome-ignore lint/performance/noImgElement: external imager */}
|
||||
<img className="w-[54px] h-[62px] bg-no-repeat bg-center" src={avatar} alt="" />
|
||||
<Image className="bg-no-repeat bg-center" src={avatar} alt="" width={54} height={62} unoptimized />
|
||||
<span className="-ml-2 font-semibold" style={{ color: "var(--color-navbar-text, var(--color-text))" }}>{session.user.name}</span>
|
||||
</summary>
|
||||
<div
|
||||
|
||||
@@ -34,6 +34,7 @@ export default getRequestConfig(async () => {
|
||||
getMessageFallback({ key }: { key: string }) {
|
||||
const fromEnglish = key
|
||||
.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);
|
||||
if (typeof fromEnglish === "string") return fromEnglish;
|
||||
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 {
|
||||
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;
|
||||
} catch { /* fall through to recovery */ }
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export function generateRequestId(): string {
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ function cleanup(): void {
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
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) {
|
||||
try {
|
||||
const windowKey = `ratelimit:${key}`;
|
||||
const windowSec = Math.ceil(windowMs / 1000);
|
||||
const current = await redis.incr(windowKey);
|
||||
if (current === 1) await redis.pexpire(windowKey, windowMs);
|
||||
const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
// === Alert service (AtomCMS → Next.js) ===========================================
|
||||
//
|
||||
@@ -111,12 +112,12 @@ async function postDiscord(input: SendAlertInput): Promise<boolean> {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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 true;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -152,7 +153,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -189,7 +190,7 @@ export async function sendAlert(input: SendAlertInput): Promise<SendAlertResult>
|
||||
} catch (e) {
|
||||
// DB unreachable / schema drift: keep the alert best-effort. We already
|
||||
// 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 };
|
||||
|
||||
@@ -14,5 +14,6 @@ const LABELS: Record<CurrencyType, string> = {
|
||||
};
|
||||
|
||||
export function currencyLabel(type: CurrencyType): string {
|
||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyType enum
|
||||
return LABELS[type];
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { exec } from "child_process";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { resolve } from "path";
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
let transporter: Transporter | 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) => {
|
||||
if (error) {
|
||||
console.error("[email] sendmail failed:", error.message);
|
||||
logger.error("Sendmail failed", { module: "email", error: error.message });
|
||||
resolve(false);
|
||||
} else {
|
||||
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> {
|
||||
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 });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `email-${timestamp}.html`;
|
||||
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${html}`;
|
||||
await writeFile(join(logDir, filename), content, "utf-8");
|
||||
console.log(`[email] Written to storage/logs/${filename}`);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(resolve(logDir, filename), content, "utf-8");
|
||||
logger.info("Email written to file", { module: "email", filename });
|
||||
return true;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +82,7 @@ export async function sendMail(to: string, subject: string, html: string): Promi
|
||||
await r.emails.send({ from, to, subject, html });
|
||||
return true;
|
||||
} 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 });
|
||||
return true;
|
||||
} 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" });
|
||||
clearTimeout(timer);
|
||||
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];
|
||||
if (entry?.proxy === "yes") return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
|
||||
return { blocked: false };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import net from "node:net";
|
||||
import { CurrencyType } from "@/lib/services/currency";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export interface RconPayload {
|
||||
key: string;
|
||||
@@ -47,7 +48,7 @@ export function tcpTransport(opts: {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function sendCurrency(
|
||||
data: { credits: { increment: amount } },
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyName union, not "credits"
|
||||
const t = TYPE_VALUE[type];
|
||||
await deps.db.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: t } },
|
||||
|
||||
@@ -58,6 +58,7 @@ class SiteSettings {
|
||||
async get(key: string, fallback: string | null = null): Promise<string | null> {
|
||||
const map = await this.load();
|
||||
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;
|
||||
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