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
@@ -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: "🚪" };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user