Author SHA1 Message Date
openhands 2249303dbd chore(lint): remove dead eslint-disable directives
CI / check (push) Successful in 30s
CI / tests-imaging-browsers (push) Successful in 1m31s
CI / tests-unit (push) Successful in 1m45s
CI / tests-integration (push) Successful in 1m49s
CI / tests-quality (push) Successful in 47s
CI / tests-ui (push) Successful in 4m24s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m39s
The project lints with Biome only, so none of these 78 directives across 57
files ever suppressed anything. Directives that carried the rationale for a
flagged dynamic index access keep that rationale as a plain comment.
2026-10-18 14:50:51 +02:00
openhands 07c5904879 test: make coverage opt-in and ratchet thresholds to the measured floor
Coverage was enabled by default, which forced v8 instrumentation on every run
and made a focused `vitest run <file>` fail the global thresholds that a
single file can never satisfy. `pnpm test:coverage` now opts in explicitly
while `pnpm test` stays fast.

Thresholds ratcheted from 36/29/30/37 to the measured full-suite floor of
37/30/31/38, so a drop in coverage fails again. Also drops a stale comment
about a parent Laravel repo and a dead .next.prev exclude from the config.
2026-10-18 14:50:50 +02:00
openhands 4776a41c01 fix(env): align suggested DB pool size with MariaDB max_connections
The example .env suggested DATABASE_POOL_SIZE=150 and connection_limit=150.
MariaDB defaults max_connections to 151, so a single CMS instance could claim
the server's entire connection budget, starving the emulator and any second
instance. The README already documents a pool size of 25; the example now
matches and notes that DATABASE_POOL_SIZE is the value that actually applies.
2026-10-18 14:50:33 +02:00
openhands a32cf6119b perf(build): measure the homepage and fix optimizePackageImports
/ is the highest-traffic route but was missing from both the public-route
instrumentation and the JS size budgets, so a payload regression there could
never fail the build. It is now wrapped in withPublicPagePerformance and
budgeted alongside the other public routes.

optimizePackageImports listed date-fns, which is not a dependency (a no-op),
while omitting the barrel-heavy packages that are actually imported: motion,
@dnd-kit/core, @dnd-kit/sortable and @tanstack/react-virtual.
2026-10-18 14:50:32 +02:00
openhands bb3799f705 perf(i18n): stop serializing the full catalogue on public pages
The root layout fed the entire ~254 KB message catalogue to
NextIntlClientProvider, which serializes it into the RSC payload of every
page. A public page only reads ~59 KB of it; the ~195 KB difference is staff
tool copy that only /admin, /admin-next and /mod ever render.

The root provider now installs the reduced catalogue and each staff route
group re-installs the full one, since a nested NextIntlClientProvider replaces
the parent message set rather than merging into it. pages.admin.actions stays
public because the shared useServerAction hook runs on public pages too.

Measured: 253 KB -> 59 KB of messages per public page (raw JSON).

Guard tests cover all three halves: the root keeps using the reduced
catalogue, every staff layout installs its own full provider, and no public
tree reads a staff-only namespace.
2026-10-18 14:50:26 +02:00
openhands d28dde459a perf(auth): memoize auth() per request with React cache
A single authenticated render called auth() 4-5 times (access guard, site
layout, both permission loaders, the page itself), and every call re-parsed
the session cookie, decoded and HMAC-verified the JWT and ran the jwt/session
callbacks. Wrapping NextAuth's handler in React's cache scopes that work to
one verification per request.

Outside a render (scripts, jobs worker) React's cache is a pass-through, so
those call sites behave exactly as before.
2026-10-18 14:49:34 +02:00
72 changed files with 225 additions and 118 deletions

No files matched your search

+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+5
View File
@@ -2,6 +2,11 @@
"schemaVersion": 1,
"mode": "enforce",
"routes": [
{
"route": "/",
"maxInitialRawBytes": 716800,
"maxInitialGzipBytes": 225280
},
{
"route": "/me",
"maxInitialRawBytes": 716800,
-1
View File
@@ -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");
+1 -1
View 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 } });
+2 -2
View File
@@ -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] },
}),
),
+1 -1
View File
@@ -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");
-4
View File
@@ -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 */
-2
View File
@@ -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}`;
+1 -3
View File
@@ -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);
-1
View File
@@ -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}
-1
View File
@@ -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}
-1
View File
@@ -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}
-1
View File
@@ -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=""
-1
View File
@@ -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`}
-1
View File
@@ -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`}
-1
View File
@@ -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=""
-1
View File
@@ -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=""
+1 -1
View File
@@ -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],
-1
View File
@@ -191,7 +191,6 @@ async function ArticlePage({
}
>
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
+4 -1
View File
@@ -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);
-2
View File
@@ -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=""
-1
View File
@@ -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`}
+1 -1
View File
@@ -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: "🚪" };
}
-1
View File
@@ -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=""
-1
View File
@@ -366,7 +366,6 @@ async function ProfilePage({
flexWrap: "wrap",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<ProfileImage
src={avatar}
alt={user.username}
+11 -1
View File
@@ -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>
);
}
-1
View File
@@ -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=""
-1
View File
@@ -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}
-1
View File
@@ -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" />
-1
View File
@@ -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=""
-1
View File
@@ -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=""
+9 -3
View File
@@ -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>
);
}
-1
View File
@@ -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) })}
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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)",
}}
+3 -3
View File
@@ -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] },
+1 -1
View File
@@ -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.
+1 -3
View File
@@ -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",
},
-2
View File
@@ -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) => {
+1 -1
View File
@@ -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
View File
@@ -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;
+9 -3
View File
@@ -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>
);
}
-1
View File
@@ -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}
-1
View File
@@ -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}
-1
View File
@@ -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}
-1
View File
@@ -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,
+1 -1
View File
@@ -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);
+1 -3
View File
@@ -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}
+1 -1
View File
@@ -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++;
}
-1
View File
@@ -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
View File
@@ -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);
+62 -1
View File
@@ -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([]);
});
});
+35 -9
View File
@@ -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;
}
+1
View File
@@ -4,6 +4,7 @@ import { collectPerformance } from "./performance-diagnostics";
import { type PerformanceSample, recordPerformance } from "./performance-store";
type PublicRoute =
| "/"
| "/me"
| "/news"
| "/news/[slug]"
+1 -1
View File
@@ -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];
}
-2
View File
@@ -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;
+1 -1
View File
@@ -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` };
+1 -1
View File
@@ -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 } },
+1 -1
View File
@@ -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;
}
+4 -4
View File
@@ -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
View File
@@ -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: [