Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2249303dbd | ||
|
|
07c5904879 | ||
|
|
4776a41c01 | ||
|
|
a32cf6119b | ||
|
|
bb3799f705 | ||
|
|
d28dde459a |
No files matched your search
+7
-2
@@ -3,8 +3,13 @@
|
||||
# ==============================================================================
|
||||
|
||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=150
|
||||
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
|
||||
# `connection_limit` in the URL is only honoured by tools that read the URL
|
||||
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=25
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||
|
||||
|
||||
+9
-1
@@ -158,7 +158,15 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
||||
// Barrel-heavy packages: without this every import pulls the whole
|
||||
// module graph into the chunk that first touches it.
|
||||
optimizePackageImports: [
|
||||
"lucide-react",
|
||||
"motion",
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/sortable",
|
||||
"@tanstack/react-virtual",
|
||||
],
|
||||
useTypeScriptCli: true,
|
||||
hideLogsAfterAbort: true,
|
||||
},
|
||||
|
||||
+3
-3
@@ -18,8 +18,8 @@
|
||||
"format": "biome format --write .",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
|
||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||
"test": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=true",
|
||||
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
@@ -34,7 +34,7 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"schemaVersion": 1,
|
||||
"mode": "enforce",
|
||||
"routes": [
|
||||
{
|
||||
"route": "/",
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/me",
|
||||
"maxInitialRawBytes": 716800,
|
||||
|
||||
@@ -60,7 +60,6 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const gif = await toBadgeGif(buffer);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, gif);
|
||||
} catch {
|
||||
back("error", "Could not process or write the badge file");
|
||||
|
||||
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
// key is one of 3 known const values
|
||||
comment: COMMENTS[key] ?? null,
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
|
||||
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
POINTS_KEYS.map((key) =>
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
// key from POINTS_KEYS const
|
||||
.values({ key, value: values[key], comment: "Radio points" })
|
||||
.onDuplicateKeyUpdate({
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
// key from POINTS_KEYS const
|
||||
set: { value: values[key] },
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
||||
export async function applyPreset(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
// guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect("/admin/theme");
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function saveFavicon(
|
||||
}
|
||||
|
||||
const buffer = upload.bytes;
|
||||
// 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}`;
|
||||
@@ -46,7 +44,6 @@ export async function saveFavicon(
|
||||
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 */
|
||||
@@ -85,7 +82,6 @@ export async function deleteFavicon(): Promise<{
|
||||
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 */
|
||||
|
||||
@@ -29,9 +29,7 @@ export async function saveLogo(
|
||||
}
|
||||
|
||||
const buffer = upload.bytes;
|
||||
// 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}`;
|
||||
|
||||
@@ -278,14 +278,12 @@ export async function uploadBackground(formData: FormData): Promise<void> {
|
||||
|
||||
let uploaded = false;
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(MEDIA_ROOT, { 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();
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
await writeSetting("background_image", `/api/media/${name}`);
|
||||
await writeSetting(
|
||||
@@ -319,7 +317,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
|
||||
const name = String(formData.get("preset") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
// guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect(MANAGER_PATH);
|
||||
|
||||
|
||||
@@ -142,7 +142,6 @@ export default async function ApplyStaffPage({
|
||||
<article key={String(position.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={rankName}
|
||||
|
||||
@@ -120,7 +120,6 @@ export default async function ApplyTeamPage({
|
||||
<article key={String(team.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={team.rankName}
|
||||
|
||||
@@ -48,7 +48,6 @@ export default async function BadgesPage() {
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
|
||||
alt={badge.badgeName}
|
||||
|
||||
@@ -190,7 +190,6 @@ export default async function DrawBadgePage({
|
||||
{b.badgeUrl ? (
|
||||
// Badge images are arbitrary external URLs stored by the
|
||||
// draw-badge tool, so a plain <img> is correct here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={b.badgeUrl}
|
||||
alt=""
|
||||
|
||||
@@ -149,7 +149,6 @@ export default async function GuildPage({
|
||||
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{guild.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||
alt={`${guild.name} badge`}
|
||||
|
||||
@@ -74,7 +74,6 @@ export default async function GuildsPage() {
|
||||
}}
|
||||
>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt={`${g.name} badge`}
|
||||
|
||||
@@ -110,7 +110,6 @@ export default async function HelpCategoryPage({
|
||||
|
||||
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
|
||||
{imageSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
|
||||
@@ -159,7 +159,6 @@ export default async function HelpCenterPage() {
|
||||
}}
|
||||
>
|
||||
{c.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={c.imageUrl}
|
||||
alt=""
|
||||
|
||||
@@ -171,7 +171,7 @@ export default async function LeaderboardPage({
|
||||
const rows = await (active === "credits"
|
||||
? loadCreditsRows()
|
||||
: active === "diamonds" || active === "duckets"
|
||||
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
|
||||
? // active validated as "diamonds"|"duckets" in this branch
|
||||
loadCurrencyRows(CURRENCY_TYPE[active])
|
||||
: loadSettingsRows(
|
||||
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
|
||||
|
||||
@@ -191,7 +191,6 @@ async function ArticlePage({
|
||||
}
|
||||
>
|
||||
{article.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={article.image}
|
||||
alt=""
|
||||
|
||||
@@ -23,6 +23,7 @@ import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
|
||||
@@ -238,7 +239,7 @@ async function getHotelData() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
async function Home() {
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect("/me");
|
||||
|
||||
@@ -1148,3 +1149,5 @@ export default async function Home() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/", Home);
|
||||
@@ -108,7 +108,6 @@ export default async function RareCategoryPage({
|
||||
}}
|
||||
>
|
||||
{badgeSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={badgeSrc}
|
||||
alt={`${cat.name} badge`}
|
||||
@@ -150,7 +149,6 @@ export default async function RareCategoryPage({
|
||||
<tr key={String(r.id)}>
|
||||
<td>
|
||||
{iconSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
|
||||
@@ -88,7 +88,6 @@ export default async function RareValuesPage() {
|
||||
>
|
||||
<div className="content-card-head">
|
||||
{badgeSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={badgeSrc}
|
||||
alt={`${c.name} badge`}
|
||||
|
||||
@@ -16,7 +16,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
|
||||
// STATE_LABELS has known keys, fallback provided
|
||||
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +249,6 @@ export default async function ShopPage({
|
||||
}}
|
||||
>
|
||||
{a.iconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.iconUrl}
|
||||
alt=""
|
||||
|
||||
@@ -366,7 +366,6 @@ async function ProfilePage({
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<ProfileImage
|
||||
src={avatar}
|
||||
alt={user.username}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { env } from "@/env";
|
||||
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
||||
@@ -27,5 +29,13 @@ export default async function AdminNextLayout({
|
||||
notFound();
|
||||
}
|
||||
|
||||
return children;
|
||||
// Staff tree: re-install the full catalogue the root layout scopes down to
|
||||
// the public subset (nested providers replace the parent set).
|
||||
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,6 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
|
||||
{ads.map((ad) => (
|
||||
<tr key={ad.id}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ad.image}
|
||||
alt=""
|
||||
|
||||
@@ -103,7 +103,6 @@ export default async function AdminCalendarCampaign({
|
||||
|
||||
<div className="admin-card flex items-center gap-5 mb-6">
|
||||
{campaign.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="w-24 h-24 object-cover"
|
||||
src={campaign.image}
|
||||
|
||||
@@ -98,7 +98,6 @@ export default async function AdminCalendar() {
|
||||
className="admin-card hover article"
|
||||
>
|
||||
{c.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="article-img" src={c.image} alt={c.name} />
|
||||
) : (
|
||||
<div className="article-img" />
|
||||
|
||||
@@ -96,7 +96,6 @@ export default async function AdminGuildDetailPage({
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{guild.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||
alt=""
|
||||
|
||||
@@ -155,7 +155,6 @@ export default async function AdminGuildsPage({
|
||||
<td className="muted">{g.id}</td>
|
||||
<td>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt=""
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages, getTranslations } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
||||
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
||||
@@ -40,8 +41,13 @@ export default async function AdminLayout({
|
||||
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
||||
}
|
||||
|
||||
// The root layout ships the reduced catalogue so public pages do not carry
|
||||
// the ~195 KB of staff-tool strings. Nested providers replace the parent
|
||||
// set rather than merging, so staff route groups re-install the full one.
|
||||
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||
<div
|
||||
@@ -56,7 +62,7 @@ export default async function AdminLayout({
|
||||
</div>
|
||||
</AdminMobileWrapper>
|
||||
<AdminToaster />
|
||||
</>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ export default async function AdminPhotos({
|
||||
className="admin-card"
|
||||
style={{ padding: 0, overflow: "hidden" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={t("photoAlt", { id: String(p.id) })}
|
||||
|
||||
@@ -62,7 +62,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
|
||||
// only called with hardcoded keys
|
||||
const v = obj[key];
|
||||
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
||||
}
|
||||
@@ -112,7 +112,7 @@ function findNumberDeep(
|
||||
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
|
||||
// keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (
|
||||
|
||||
@@ -577,7 +577,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
|
||||
)}
|
||||
<TableCell>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={icon}
|
||||
alt={item.name}
|
||||
@@ -733,7 +732,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
|
||||
>
|
||||
<TableCell>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={icon}
|
||||
alt={g.base.name}
|
||||
|
||||
@@ -493,7 +493,7 @@ export default async function AdminTheme({
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 4,
|
||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed theme color key list
|
||||
// k from fixed theme color key list
|
||||
background: theme.settings[k] ?? "#64748b",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
|
||||
@@ -356,7 +356,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
|
||||
// rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||
) as Record<BadgeRarityKey, BoardCore>;
|
||||
|
||||
@@ -398,7 +398,7 @@ export async function GET(req: Request) {
|
||||
totalBadgesViewer = tb;
|
||||
achievementViewer = ach;
|
||||
rarityViewer = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
// rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rvs[i]]),
|
||||
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
|
||||
}
|
||||
@@ -414,7 +414,7 @@ export async function GET(req: Request) {
|
||||
viewerEntry: achievementViewer,
|
||||
},
|
||||
rarity: Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
|
||||
// rk from rarityKeys const
|
||||
rarityKeys.map((rk) => [
|
||||
rk,
|
||||
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
|
||||
async () =>
|
||||
type === "credits"
|
||||
? await loadCreditsRows()
|
||||
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
||||
: // type validated to "diamonds"|"duckets"
|
||||
await loadCurrencyRows(CURRENCY_TYPE[type]),
|
||||
// Rankings are re-sorted on every read; serving the previous order for a
|
||||
// minute keeps a busy leaderboard off the database.
|
||||
|
||||
@@ -51,7 +51,6 @@ export async function GET(
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(filePath)) {
|
||||
return new NextResponse("Not found", {
|
||||
status: 404,
|
||||
@@ -59,7 +58,6 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const bytes = await readFile(filePath);
|
||||
const mime: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
@@ -76,7 +74,7 @@ export async function GET(
|
||||
headers: {
|
||||
...SECURITY_HEADERS,
|
||||
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
// ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||
},
|
||||
|
||||
@@ -12,11 +12,9 @@ import { PERMS } from "@/lib/permissions";
|
||||
*/
|
||||
export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
|
||||
const dir = MEDIA_ROOT;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(dir)) {
|
||||
return apiOk({ 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) => {
|
||||
|
||||
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
|
||||
"count",
|
||||
];
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
// keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (
|
||||
|
||||
+6
-1
@@ -13,6 +13,7 @@ import { ThemeVars } from "@/components/theme-vars";
|
||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||
import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { publicClientMessages } from "@/lib/i18n-client-messages";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -68,7 +69,11 @@ export default async function RootLayout({
|
||||
}) {
|
||||
await enforceSiteAccess();
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
// Only the namespaces non-staff routes can reach: the full catalogue is
|
||||
// ~254 KB and would otherwise be serialised into every public page's RSC
|
||||
// payload. `/admin`, `/admin-next` and `/mod` install a nested provider
|
||||
// seeded with the full set (see i18n-client-messages).
|
||||
const messages = publicClientMessages(await getMessages());
|
||||
const defaultDark = await siteSettings.getBool("default_dark", false);
|
||||
const nitroUrl = await siteSettings.get("nitro_client_url", "");
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages, getTranslations } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import Link from "@/components/link";
|
||||
@@ -81,8 +82,13 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
|
||||
].filter((item) => item.show);
|
||||
|
||||
// The root layout ships the reduced catalogue so public pages do not carry
|
||||
// the ~195 KB of staff-tool strings. Nested providers replace the parent
|
||||
// set rather than merging, so staff route groups re-install the full one.
|
||||
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||
<div
|
||||
data-admin
|
||||
@@ -140,6 +146,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,6 @@ export function ArticleCard({
|
||||
<div className="admin-card overflow-hidden flex flex-col">
|
||||
{confirmDialog}
|
||||
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image || ""}
|
||||
alt={title}
|
||||
|
||||
@@ -296,7 +296,6 @@ export function ArticleForm({
|
||||
/>
|
||||
</div>
|
||||
{image && !imageError ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt={t("imagePreview")}
|
||||
|
||||
@@ -715,7 +715,6 @@ function FurniThumb({
|
||||
className="flex items-center justify-center rounded bg-muted/50"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={primary}
|
||||
alt={item.itemName}
|
||||
|
||||
@@ -893,7 +893,7 @@ export function SortableTree({
|
||||
handleToggleEnabled,
|
||||
handleAddSubpage,
|
||||
],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -34,7 +34,6 @@ export function FurniIcon({
|
||||
|
||||
return (
|
||||
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={localUrl}
|
||||
alt={classname}
|
||||
|
||||
@@ -231,7 +231,6 @@ function FurniThumb({
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={primary}
|
||||
alt={classname}
|
||||
|
||||
@@ -192,7 +192,6 @@ export function ShopFurniIcon({
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={primary}
|
||||
alt={classname}
|
||||
|
||||
@@ -226,7 +226,6 @@ export function AdminMediaGrid() {
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={validImageUrl(f.url)}
|
||||
alt={f.name}
|
||||
|
||||
@@ -156,7 +156,6 @@ export function MediaPicker({
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={validImageUrl(f.url)}
|
||||
alt={f.name}
|
||||
|
||||
@@ -51,7 +51,6 @@ export function FurniThumb({
|
||||
className="flex items-center justify-center rounded bg-muted/50"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={primary}
|
||||
alt={item.itemName}
|
||||
|
||||
@@ -405,7 +405,7 @@ export function ThemeManagerPanel({
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
|
||||
// k from fixed key list
|
||||
background: palette.light[k],
|
||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
||||
marginLeft: -3,
|
||||
|
||||
@@ -44,7 +44,7 @@ export function NavbarColorPicker() {
|
||||
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
|
||||
// index validated by findIndex + Math.max, guard below
|
||||
const c = COLORS[index];
|
||||
if (!c) return;
|
||||
document.documentElement.style.setProperty("--color-navbar", c.value);
|
||||
|
||||
@@ -29,7 +29,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
// has to hold the overlay itself.
|
||||
const [closing, setClosing] = useState(false);
|
||||
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
|
||||
// openIndex is numeric array index, guarded by null check
|
||||
const active = openIndex !== null ? photos[openIndex] : null;
|
||||
|
||||
const close = useCallback(() => {
|
||||
@@ -97,7 +97,6 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
}}
|
||||
aria-label={p.alt}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.alt}
|
||||
@@ -180,7 +179,6 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={active.url}
|
||||
alt={active.alt}
|
||||
|
||||
@@ -59,7 +59,7 @@ function scanCharWidths(
|
||||
|
||||
const w = end - start;
|
||||
if (w > 0) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
|
||||
// ci increments within CHAR_ORDER bounds
|
||||
chars.push({ char: CHAR_ORDER[ci], x: start, w });
|
||||
ci++;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ export function AvatarImage({
|
||||
(options?.headOnly ? "l" : max <= 40 ? "s" : max <= 80 ? "m" : "l");
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getAvatarUrl(figure, { ...options, size: autoSize })}
|
||||
alt={alt}
|
||||
|
||||
@@ -41,6 +41,8 @@ const routeMocks = vi.hoisted(() => {
|
||||
}
|
||||
return translate;
|
||||
}),
|
||||
getLocale: vi.fn(async () => "en"),
|
||||
getMessages: vi.fn(async () => ({})),
|
||||
notFound: vi.fn((): never => {
|
||||
throw new Error("NEXT_NOT_FOUND");
|
||||
}),
|
||||
@@ -58,6 +60,8 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: routeMocks.getTranslations,
|
||||
getLocale: routeMocks.getLocale,
|
||||
getMessages: routeMocks.getMessages,
|
||||
}));
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
|
||||
|
||||
+14
-1
@@ -1,6 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { cache } from "react";
|
||||
import { env } from "@/env";
|
||||
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
|
||||
import {
|
||||
@@ -25,7 +26,7 @@ import { db, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const { handlers, signOut, auth } = NextAuth({
|
||||
const nextAuth = NextAuth({
|
||||
trustHost: true,
|
||||
secret: env.AUTH_SECRET,
|
||||
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
|
||||
@@ -161,3 +162,15 @@ export const { handlers, signOut, auth } = NextAuth({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { handlers, signOut } = nextAuth;
|
||||
|
||||
/**
|
||||
* `auth()` re-parses the session cookie, decodes and HMAC-verifies the JWT and
|
||||
* runs the `jwt`/`session` callbacks on every call. A single authenticated
|
||||
* render calls it from the access guard, the site layout, both permission
|
||||
* loaders and the page itself — 4-5 times on the critical path. React's
|
||||
* `cache` scopes the memo to the current request, so those all share one
|
||||
* verification. Outside a render (scripts, worker) it is a pass-through.
|
||||
*/
|
||||
export const auth = cache(nextAuth.auth);
|
||||
@@ -85,7 +85,12 @@ describe("publicClientMessages", () => {
|
||||
if (key === "admin") continue;
|
||||
expect(result[key]).toBeDefined();
|
||||
}
|
||||
expect(result.pages.admin).toBeUndefined();
|
||||
// `pages.admin` survives only in the shape the shared server-action hook
|
||||
// needs: the generic `actions` copy, nothing staff-specific.
|
||||
expect(Object.keys(result.pages.admin)).toEqual(["actions"]);
|
||||
expect(result.pages.admin.actions.saved).toBe(en.pages.admin.actions.saved);
|
||||
expect(result.pages.admin.nav).toBeUndefined();
|
||||
expect(result.pages.admin.catalog).toBeUndefined();
|
||||
});
|
||||
|
||||
it("actually shrinks the catalogue", async () => {
|
||||
@@ -111,3 +116,59 @@ describe("publicClientMessages", () => {
|
||||
expect(en.pages.admin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Scoping only works if the reduced catalogue goes to the client *and* every
|
||||
* staff route group re-installs the full one. These two halves drift silently:
|
||||
* narrowing the root without adding a staff provider renders raw keys in the
|
||||
* admin panel, and the reverse just costs bytes. Both are asserted here.
|
||||
*/
|
||||
describe("client catalogue scoping is wired up", () => {
|
||||
const STAFF_LAYOUTS = [
|
||||
"src/app/admin/layout.tsx",
|
||||
"src/app/admin-next/layout.tsx",
|
||||
"src/app/mod/layout.tsx",
|
||||
];
|
||||
|
||||
it("the root layout feeds the reduced catalogue to its provider", () => {
|
||||
const src = readFileSync(join(process.cwd(), "src/app/layout.tsx"), "utf8");
|
||||
expect(src).toContain("publicClientMessages(await getMessages())");
|
||||
});
|
||||
|
||||
for (const layout of STAFF_LAYOUTS) {
|
||||
it(`${layout} re-installs the full catalogue`, () => {
|
||||
const src = readFileSync(join(process.cwd(), layout), "utf8");
|
||||
expect(src).toContain("NextIntlClientProvider");
|
||||
expect(src).toContain("getMessages()");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A client component that reads `admin.*` / `pages.admin.*` outside a staff
|
||||
* route group would fall back to raw keys once the root catalogue is scoped,
|
||||
* because the provider it inherits no longer carries them. Staff trees are the
|
||||
* ones that install the full catalogue; the allowlist holds shared code that
|
||||
* deliberately runs on public pages too.
|
||||
*/
|
||||
describe("staff namespace consumers sit in a staff tree", () => {
|
||||
const STAFF_TREE =
|
||||
/^src[\\/](app[\\/](admin|admin-next|mod)[\\/]|components[\\/]admin[\\/]|features[\\/](catalog|operations|history|housekeeping)[\\/])/;
|
||||
const ALLOWLIST = new Set(["src/hooks/use-server-action.ts"]);
|
||||
const CLIENT_STAFF_NS = /useTranslations\((["`])((admin)|(pages\.admin))/;
|
||||
|
||||
it("there is source to scan", () => {
|
||||
expect(sourceFiles(join(process.cwd(), "src")).length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("no stray consumer of a staff namespace", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of sourceFiles(join(process.cwd(), "src"))) {
|
||||
const rel = file.slice(process.cwd().length + 1).replaceAll("\\", "/");
|
||||
if (rel.includes(".test.")) continue;
|
||||
if (STAFF_TREE.test(rel) || ALLOWLIST.has(rel)) continue;
|
||||
if (CLIENT_STAFF_NS.test(readFileSync(file, "utf8"))) offenders.push(rel);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,22 +3,35 @@ import type { AbstractIntlMessages } from "next-intl";
|
||||
/**
|
||||
* Client-side message scoping.
|
||||
*
|
||||
* `getMessages()` hands back the whole catalogue, and the root layout feeds all
|
||||
* of it to `NextIntlClientProvider`, which serialises every message into the RSC
|
||||
* payload of every page. The catalogue is ~235 KB of which `pages.admin` and
|
||||
* `admin` together are ~177 KB — and no public page ever reads either.
|
||||
* `getMessages()` hands back the whole catalogue, and the root layout feeds it
|
||||
* to `NextIntlClientProvider`, which serialises every message into the RSC
|
||||
* payload of every page. The catalogue is ~254 KB of which `pages.admin` and
|
||||
* `admin` together are ~195 KB — and no public page ever reads either.
|
||||
*
|
||||
* Nested `NextIntlClientProvider`s REPLACE the parent's messages rather than
|
||||
* merging them (see use-intl's IntlProvider: `messages ?? parent.messages`), so
|
||||
* a segment cannot ask for "just my part". Instead the public layout installs a
|
||||
* provider seeded with the public subset, which shrinks the payload for every
|
||||
* public page while leaving /admin, /mod, /client and /admin-next on the full set.
|
||||
* a segment cannot ask for "just my part". The root layout therefore installs
|
||||
* the reduced catalogue, and the staff route groups (`/admin`, `/admin-next`,
|
||||
* `/mod`) each install a nested provider seeded with the full one, which keeps
|
||||
* staff tools complete while public pages ship ~59 KB instead of ~254 KB.
|
||||
*/
|
||||
|
||||
/** Top-level namespaces plus `pages.*` sub-namespaces reserved for staff tools. */
|
||||
const ADMIN_ONLY_NAMESPACES = new Set(["admin"]);
|
||||
const ADMIN_ONLY_PAGE_NAMESPACES = new Set(["admin"]);
|
||||
|
||||
/**
|
||||
* `pages.admin` sub-namespaces that are NOT staff-only because a shared
|
||||
* client component reads them on public pages too.
|
||||
*
|
||||
* `useServerAction` (src/hooks/use-server-action.ts) resolves its default
|
||||
* success/error toast copy from `pages.admin.actions` and is used by
|
||||
* `/events/[slugOrId]` and `/polls/[id]`, so that one sub-namespace has to
|
||||
* survive the filter — the strings in it are generic ("Saved successfully."),
|
||||
* not staff-tool copy.
|
||||
*/
|
||||
const PUBLIC_PAGE_NAMESPACES = new Set(["actions"]);
|
||||
|
||||
/**
|
||||
* Drop the staff-tool namespaces from a full catalogue.
|
||||
*
|
||||
@@ -43,8 +56,21 @@ export function publicClientMessages(
|
||||
for (const [pageKey, pageValue] of Object.entries(
|
||||
value as AbstractIntlMessages,
|
||||
)) {
|
||||
if (ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) continue;
|
||||
pages[pageKey] = pageValue;
|
||||
if (!ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) {
|
||||
pages[pageKey] = pageValue;
|
||||
continue;
|
||||
}
|
||||
// Staff-only page namespace: keep only the sub-namespaces that a
|
||||
// shared client component reads on public pages as well.
|
||||
const shared: AbstractIntlMessages = {};
|
||||
for (const [sharedKey, sharedValue] of Object.entries(
|
||||
pageValue as AbstractIntlMessages,
|
||||
)) {
|
||||
if (PUBLIC_PAGE_NAMESPACES.has(sharedKey)) {
|
||||
shared[sharedKey] = sharedValue;
|
||||
}
|
||||
}
|
||||
if (Object.keys(shared).length > 0) pages[pageKey] = shared;
|
||||
}
|
||||
result.pages = pages;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { collectPerformance } from "./performance-diagnostics";
|
||||
import { type PerformanceSample, recordPerformance } from "./performance-store";
|
||||
|
||||
type PublicRoute =
|
||||
| "/"
|
||||
| "/me"
|
||||
| "/news"
|
||||
| "/news/[slug]"
|
||||
|
||||
@@ -14,6 +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
|
||||
// type is CurrencyType enum
|
||||
return LABELS[type];
|
||||
}
|
||||
@@ -55,12 +55,10 @@ async function writeToFile(
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
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}`;
|
||||
// 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;
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function checkVpn(ip: string): Promise<IpVerdict> {
|
||||
string,
|
||||
{ proxy?: string; type?: string }
|
||||
>;
|
||||
// eslint-disable-next-line security/detect-object-injection -- ip is the API response key from proxycheck
|
||||
// ip is the API response key from proxycheck
|
||||
const entry = d?.[ip];
|
||||
if (entry?.proxy === "yes")
|
||||
return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function sendCurrency(
|
||||
data: { credits: { increment: amount } },
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyName union, not "credits"
|
||||
// type is CurrencyName union, not "credits"
|
||||
const t = TYPE_VALUE[type];
|
||||
await deps.db.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: t } },
|
||||
|
||||
@@ -128,7 +128,7 @@ class SiteSettings {
|
||||
): 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`
|
||||
// guarded by `key in DEFAULTS`
|
||||
if (key in DEFAULTS) return DEFAULTS[key] as string;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function fallbackFor(key: string): string {
|
||||
const preset = PRESETS["Atom (golden)"];
|
||||
if (key.endsWith("_dark")) {
|
||||
const base = key.slice(0, -"_dark".length) as ThemeColorKey;
|
||||
return preset.dark[base] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS
|
||||
return preset.dark[base] ?? ""; // key derived from internal THEME_COLOR_KEYS
|
||||
}
|
||||
if (key in preset.light) {
|
||||
return preset.light[key as ThemeColorKey] ?? "";
|
||||
@@ -63,7 +63,7 @@ function fallbackFor(key: string): string {
|
||||
theme_preset: "Atom (golden)",
|
||||
...BACKGROUND_DEFAULTS,
|
||||
};
|
||||
return extras[key] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal EXTRA_KEYS
|
||||
return extras[key] ?? ""; // key derived from internal EXTRA_KEYS
|
||||
}
|
||||
|
||||
export async function listCustomThemes(): Promise<CustomTheme[]> {
|
||||
@@ -106,9 +106,9 @@ export async function upsertCustomTheme(
|
||||
if (id) {
|
||||
const idx = themes.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
themes[idx] = { ...themes[idx], name: trimmed, settings }; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array
|
||||
themes[idx] = { ...themes[idx], name: trimmed, settings }; // idx from findIndex over internal array
|
||||
await persist(themes);
|
||||
return themes[idx]; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array
|
||||
return themes[idx]; // idx from findIndex over internal array
|
||||
}
|
||||
}
|
||||
const theme: CustomTheme = {
|
||||
|
||||
+17
-14
@@ -1,8 +1,9 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Local config so Vitest does not walk up into the parent Laravel repo's
|
||||
// vite.config.js (this app lives inside the Laravel project tree).
|
||||
// Project-local config: keeps the `@/` alias, the server-only stub and the
|
||||
// env fallbacks with the repo instead of relying on whatever vite config a
|
||||
// parent directory happens to provide.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -26,22 +27,24 @@ export default defineConfig({
|
||||
DATABASE_URL: "mysql://root:root@localhost:3306/test",
|
||||
},
|
||||
testTimeout: 10000,
|
||||
exclude: [
|
||||
"integration/**",
|
||||
"e2e/**",
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
".next.prev/**",
|
||||
],
|
||||
exclude: ["integration/**", "e2e/**", "node_modules/**", ".next/**"],
|
||||
coverage: {
|
||||
enabled: true,
|
||||
// Off unless asked for (`pnpm test:coverage` passes
|
||||
// `--coverage.enabled=true`): a focused `vitest run <file>` should
|
||||
// not fail the global thresholds it cannot possibly meet, and
|
||||
// measuring v8 coverage on every run costs real time.
|
||||
enabled: false,
|
||||
provider: "v8",
|
||||
reporter: ["text", "lcov", "html"],
|
||||
// Ratchet floor: measured 37.14 / 30.09 / 31.74 / 38.13 on the full
|
||||
// suite (statements/branches/functions/lines). Rounded down so only
|
||||
// a real regression fails the build — raise these to the new
|
||||
// measured floor whenever coverage improves.
|
||||
thresholds: {
|
||||
statements: 36,
|
||||
branches: 29,
|
||||
functions: 30,
|
||||
lines: 37,
|
||||
statements: 37,
|
||||
branches: 30,
|
||||
functions: 31,
|
||||
lines: 38,
|
||||
},
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
|
||||
Reference in new issue
Block a user