From 3c9c1311ec3911ef4d1b9776fba91f8bd8f2e0ae Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 18 Jul 2026 19:08:17 +0200 Subject: [PATCH] chore: remove dead code flagged by knip Remove 19 unused source files (no importers anywhere in src/): - src/actions/admin-permissions.ts, admin-radio.ts, admin-user-edit.ts, admin-users.ts (functionality lives in @/actions/users and @/actions/permissions) - src/components/admin/confirm-action.tsx, page-header.tsx - src/components/motion-elements.tsx - src/lib/format-date.ts - src/lib/catalog-categories/* (incl. re-export barrel) - src/lib/foundation/{index,database,middleware,validation}.ts (errors/action kept, still imported directly) - src/lib/services/imager/{avatar-renderer,memory-cache}.ts - src/lib/services/nitro-assets.ts Update admin-operations-contract test to drop the two removed action-file gates (their permission coverage already exists in users.ts/permissions.ts). Add knip.json for repeatable dead-code audits. Verified: tsc --noEmit clean, next build succeeds, full test suite green (301/301). --- knip.json | 18 + package.json | 1 + pnpm-lock.yaml | 567 +++++++++++++++++ src/actions/admin-permissions.ts | 135 ---- src/actions/admin-radio.ts | 1 - src/actions/admin-user-edit.ts | 101 --- src/actions/admin-users.ts | 86 --- src/components/admin/confirm-action.tsx | 40 -- src/components/admin/page-header.tsx | 29 - src/components/motion-elements.tsx | 100 --- src/lib/admin-operations-contract.test.ts | 2 - src/lib/catalog-categories.ts | 1 - src/lib/catalog-categories/categories.ts | 695 --------------------- src/lib/catalog-categories/eras.ts | 63 -- src/lib/catalog-categories/index.ts | 99 --- src/lib/format-date.ts | 9 - src/lib/foundation/database.ts | 156 ----- src/lib/foundation/index.ts | 79 --- src/lib/foundation/middleware.ts | 102 --- src/lib/foundation/validation.ts | 99 --- src/lib/services/imager/avatar-renderer.ts | 190 ------ src/lib/services/imager/memory-cache.ts | 97 --- src/lib/services/nitro-assets.ts | 74 --- 23 files changed, 586 insertions(+), 2158 deletions(-) create mode 100644 knip.json delete mode 100644 src/actions/admin-permissions.ts delete mode 100644 src/actions/admin-radio.ts delete mode 100644 src/actions/admin-user-edit.ts delete mode 100644 src/actions/admin-users.ts delete mode 100644 src/components/admin/confirm-action.tsx delete mode 100644 src/components/admin/page-header.tsx delete mode 100644 src/components/motion-elements.tsx delete mode 100644 src/lib/catalog-categories.ts delete mode 100644 src/lib/catalog-categories/categories.ts delete mode 100644 src/lib/catalog-categories/eras.ts delete mode 100644 src/lib/catalog-categories/index.ts delete mode 100644 src/lib/format-date.ts delete mode 100644 src/lib/foundation/database.ts delete mode 100644 src/lib/foundation/index.ts delete mode 100644 src/lib/foundation/middleware.ts delete mode 100644 src/lib/foundation/validation.ts delete mode 100644 src/lib/services/imager/avatar-renderer.ts delete mode 100644 src/lib/services/imager/memory-cache.ts delete mode 100644 src/lib/services/nitro-assets.ts diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..4563b092 --- /dev/null +++ b/knip.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": [ + "src/app/**/page.{ts,tsx}", + "src/app/**/layout.{ts,tsx}", + "src/app/**/route.{ts,tsx}", + "src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}", + "src/app/**/actions.{ts,tsx}", + "next.config.ts", + "src/middleware.ts" + ], + "project": ["src/**/*.{ts,tsx}"], + "ignore": [ + "src/**/*.test.{ts,tsx}", + "scripts/**" + ], + "ignoreDependencies": ["@types/*", "knip"] +} diff --git a/package.json b/package.json index c7b847c8..2cb2c579 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@types/react-dom": "^19.2.0", "@types/sanitize-html": "^2.16.1", "dotenv": "^16.4.0", + "knip": "^6.27.0", "pino-pretty": "^13.1.3", "postcss": "^8.5.19", "prisma": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a45d628..c71da323 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: dotenv: specifier: ^16.4.0 version: 16.6.1 + knip: + specifier: ^6.27.0 + version: 6.27.0 pino-pretty: specifier: ^13.1.3 version: 13.1.3 @@ -414,9 +417,21 @@ packages: '@electric-sql/pglite@0.4.1': resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + '@emnapi/core@1.11.0': + resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.0': + resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1085,6 +1100,12 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@16.2.10': resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} @@ -1200,6 +1221,239 @@ packages: '@otplib/preset-v11@12.0.1': resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==} + '@oxc-parser/binding-android-arm-eabi@0.137.0': + resolution: {integrity: sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.137.0': + resolution: {integrity: sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.137.0': + resolution: {integrity: sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.137.0': + resolution: {integrity: sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.137.0': + resolution: {integrity: sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.137.0': + resolution: {integrity: sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.137.0': + resolution: {integrity: sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.137.0': + resolution: {integrity: sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.137.0': + resolution: {integrity: sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.137.0': + resolution: {integrity: sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.137.0': + resolution: {integrity: sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.137.0': + resolution: {integrity: sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.137.0': + resolution: {integrity: sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.137.0': + resolution: {integrity: sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.137.0': + resolution: {integrity: sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.137.0': + resolution: {integrity: sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.137.0': + resolution: {integrity: sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.137.0': + resolution: {integrity: sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.137.0': + resolution: {integrity: sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.137.0': + resolution: {integrity: sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@oxc-resolver/binding-android-arm-eabi@11.21.3': + resolution: {integrity: sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.21.3': + resolution: {integrity: sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.21.3': + resolution: {integrity: sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.21.3': + resolution: {integrity: sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.21.3': + resolution: {integrity: sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3': + resolution: {integrity: sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.3': + resolution: {integrity: sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.21.3': + resolution: {integrity: sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.21.3': + resolution: {integrity: sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.3': + resolution: {integrity: sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.3': + resolution: {integrity: sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.21.3': + resolution: {integrity: sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.21.3': + resolution: {integrity: sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.21.3': + resolution: {integrity: sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.21.3': + resolution: {integrity: sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.21.3': + resolution: {integrity: sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.21.3': + resolution: {integrity: sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.21.3': + resolution: {integrity: sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.21.3': + resolution: {integrity: sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==} + cpu: [x64] + os: [win32] + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -2134,6 +2388,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -2620,6 +2877,9 @@ packages: fast-uri@3.1.3: resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2641,6 +2901,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + framer-motion@12.42.2: resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} peerDependencies: @@ -2674,6 +2939,9 @@ packages: get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + giget@3.3.0: resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} hasBin: true @@ -2815,6 +3083,11 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + knip@6.27.0: + resolution: {integrity: sha512-CngYEYrD0n20N06FXA8n3u/0Wnnugoa+B9k14OP+iKIgkCHuzvIdsP3nfwjhByoc1WfogpxfiriMboAXFETDUw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + launder@1.7.1: resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} @@ -3140,6 +3413,13 @@ packages: otplib@12.0.1: resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} + oxc-parser@0.137.0: + resolution: {integrity: sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.21.3: + resolution: {integrity: sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3360,6 +3640,9 @@ packages: '@react-email/render': optional: true + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3445,6 +3728,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -3555,6 +3842,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3595,6 +3886,10 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + unbash@4.0.3: + resolution: {integrity: sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w==} + engines: {node: '>=14'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3709,6 +4004,10 @@ packages: jsdom: optional: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -3752,6 +4051,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3762,6 +4066,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3997,11 +4304,33 @@ snapshots: '@electric-sql/pglite@0.4.1': {} + '@emnapi/core@1.11.0': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -4415,6 +4744,20 @@ snapshots: '@kurkle/color@0.3.4': {} + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': + dependencies: + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@16.2.10': {} '@next/swc-darwin-arm64@16.2.10': @@ -4507,6 +4850,133 @@ snapshots: '@otplib/plugin-crypto': 12.0.1 '@otplib/plugin-thirty-two': 12.0.1 + '@oxc-parser/binding-android-arm-eabi@0.137.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.137.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.137.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.137.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.137.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.137.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.137.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.137.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.137.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.137.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.137.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.137.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.137.0': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.137.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.137.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.137.0': + optional: true + + '@oxc-project/types@0.137.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.21.3': + optional: true + + '@oxc-resolver/binding-android-arm64@11.21.3': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.21.3': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.21.3': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.21.3': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.21.3': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.21.3': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.21.3': + dependencies: + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.21.3': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.21.3': + optional: true + '@panva/hkdf@1.2.1': {} '@parcel/watcher-android-arm64@2.5.6': @@ -5322,6 +5792,11 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/estree@1.0.9': {} '@types/geojson@7946.0.16': {} @@ -5823,6 +6298,10 @@ snapshots: fast-uri@3.1.3: {} + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -5846,6 +6325,10 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: motion-dom: 12.42.2 @@ -5868,6 +6351,10 @@ snapshots: get-port-please@3.2.0: {} + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + giget@3.3.0: {} glob@13.0.6: @@ -6005,6 +6492,22 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + knip@6.27.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + formatly: 0.3.0 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + oxc-parser: 0.137.0 + oxc-resolver: 11.21.3 + picomatch: 4.0.5 + smol-toml: 1.7.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.3 + yaml: 2.9.0 + zod: 4.4.3 + launder@1.7.1: dependencies: dayjs: 1.11.21 @@ -6266,6 +6769,53 @@ snapshots: '@otplib/preset-default': 12.0.1 '@otplib/preset-v11': 12.0.1 + oxc-parser@0.137.0: + dependencies: + '@oxc-project/types': 0.137.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.137.0 + '@oxc-parser/binding-android-arm64': 0.137.0 + '@oxc-parser/binding-darwin-arm64': 0.137.0 + '@oxc-parser/binding-darwin-x64': 0.137.0 + '@oxc-parser/binding-freebsd-x64': 0.137.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.137.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.137.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.137.0 + '@oxc-parser/binding-linux-arm64-musl': 0.137.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.137.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.137.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.137.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.137.0 + '@oxc-parser/binding-linux-x64-gnu': 0.137.0 + '@oxc-parser/binding-linux-x64-musl': 0.137.0 + '@oxc-parser/binding-openharmony-arm64': 0.137.0 + '@oxc-parser/binding-wasm32-wasi': 0.137.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.137.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.137.0 + '@oxc-parser/binding-win32-x64-msvc': 0.137.0 + + oxc-resolver@11.21.3: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.21.3 + '@oxc-resolver/binding-android-arm64': 11.21.3 + '@oxc-resolver/binding-darwin-arm64': 11.21.3 + '@oxc-resolver/binding-darwin-x64': 11.21.3 + '@oxc-resolver/binding-freebsd-x64': 11.21.3 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.3 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.21.3 + '@oxc-resolver/binding-linux-arm64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-arm64-musl': 11.21.3 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-riscv64-musl': 11.21.3 + '@oxc-resolver/binding-linux-s390x-gnu': 11.21.3 + '@oxc-resolver/binding-linux-x64-gnu': 11.21.3 + '@oxc-resolver/binding-linux-x64-musl': 11.21.3 + '@oxc-resolver/binding-openharmony-arm64': 11.21.3 + '@oxc-resolver/binding-wasm32-wasi': 11.21.3 + '@oxc-resolver/binding-win32-arm64-msvc': 11.21.3 + '@oxc-resolver/binding-win32-x64-msvc': 11.21.3 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -6487,6 +7037,8 @@ snapshots: postal-mime: 2.7.4 standardwebhooks: 1.0.0 + resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} rollup@4.62.2: @@ -6636,6 +7188,8 @@ snapshots: signal-exit@4.1.0: {} + smol-toml@1.7.0: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -6723,6 +7277,11 @@ snapshots: tinyexec@0.3.2: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} @@ -6751,6 +7310,8 @@ snapshots: uint8array-extras@1.5.0: {} + unbash@4.0.3: {} + undici-types@6.21.0: {} undici-types@7.18.2: {} @@ -6858,6 +7419,8 @@ snapshots: - supports-color - terser + walk-up-path@4.0.0: {} + watchpack@2.5.2: dependencies: graceful-fs: 4.2.11 @@ -6924,6 +7487,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: {} + yocto-queue@0.1.0: {} zeptomatch@2.1.0: @@ -6932,3 +7497,5 @@ snapshots: graphmatch: 1.1.1 zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/src/actions/admin-permissions.ts b/src/actions/admin-permissions.ts deleted file mode 100644 index 8b5d1521..00000000 --- a/src/actions/admin-permissions.ts +++ /dev/null @@ -1,135 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; -import { requirePermission } from "@/lib/admin/guard"; -import { PERMS } from "@/lib/permissions"; -import { formPositiveBigInt } from "@/lib/form-data"; -import { prisma } from "@/lib/prisma"; -import { logServerError } from "@/lib/server-log"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -// website_permissions (model WebsitePermissions) is the CMS-owned permission -> -// minimum-rank mapping. Editable columns are exactly: permission (unique name), -// min_rank (lowest rank that may use it), description. id is BigInt and -// created_at/updated_at are managed here. - -function parseMinRank(formData: FormData): number { - const n = Number( - String(formData.get("minRank") ?? "") - .normalize("NFC") - .trim(), - ); - return Number.isInteger(n) && n >= 0 ? n : 1; -} - -export async function createPermission(formData: FormData): Promise { - const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE); - const permission = String(formData.get("permission") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const minRank = parseMinRank(formData); - const description = - String(formData.get("description") ?? "") - .normalize("NFC") - .trim() || null; - if (!permission) return; - - const now = new Date(); - try { - await prisma.websitePermissions.upsert({ - where: { permission }, - update: { minRank, description, updatedAt: now }, - create: { - permission, - minRank, - description, - createdAt: now, - updatedAt: now, - }, - }); - await logStaffActivity({ - staffId: staff.id, - action: "permission_create", - description: `Saved permission "${permission}" (min rank ${minRank})`, - targetType: "permission", - }); - } catch (error) { - logServerError("admin.permission_create_failed", error, { - staffId: staff.id, - permission, - }); - redirect("/admin/permissions?error=save"); - // ignore (e.g. constraint failure) — page re-renders current state - } - revalidatePath("/admin/permissions"); - redirect("/admin/permissions?saved=1"); -} - -export async function updatePermission(formData: FormData): Promise { - const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE); - const raw = String(formData.get("id") ?? "").normalize("NFC"); - if (!raw) return; - const id = BigInt(raw); - const permission = String(formData.get("permission") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const minRank = parseMinRank(formData); - const description = - String(formData.get("description") ?? "") - .normalize("NFC") - .trim() || null; - if (!permission) return; - - try { - await prisma.websitePermissions.update({ - where: { id }, - data: { permission, minRank, description, updatedAt: new Date() }, - }); - await logStaffActivity({ - staffId: staff.id, - action: "permission_update", - description: `Updated permission #${id} ("${permission}" min rank ${minRank})`, - targetType: "permission", - }); - } catch (error) { - logServerError("admin.permission_update_failed", error, { - staffId: staff.id, - permissionId: String(id), - }); - redirect("/admin/permissions?error=save"); - // ignore (e.g. duplicate) — page re-renders current state - } - revalidatePath("/admin/permissions"); - redirect("/admin/permissions?saved=1"); -} - -export async function deletePermission(formData: FormData): Promise { - const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - - try { - const deleted = await prisma.websitePermissions.delete({ - where: { id }, - select: { permission: true }, - }); - await logStaffActivity({ - staffId: staff.id, - action: "permission_delete", - description: `Deleted permission #${id} ("${deleted.permission}")`, - targetType: "permission", - }); - } catch (error) { - logServerError("admin.permission_delete_failed", error, { - staffId: staff.id, - permissionId: String(id), - }); - redirect("/admin/permissions?error=delete"); - // ignore (e.g. already removed) - } - revalidatePath("/admin/permissions"); - redirect("/admin/permissions?saved=1"); -} diff --git a/src/actions/admin-radio.ts b/src/actions/admin-radio.ts deleted file mode 100644 index 908fe79e..00000000 --- a/src/actions/admin-radio.ts +++ /dev/null @@ -1 +0,0 @@ -"use server"; diff --git a/src/actions/admin-user-edit.ts b/src/actions/admin-user-edit.ts deleted file mode 100644 index a5e3abfd..00000000 --- a/src/actions/admin-user-edit.ts +++ /dev/null @@ -1,101 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; -import { requirePermission } from "@/lib/admin/guard"; -import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -// users_currency.type values for the non-credits currencies (mirror send-currency.ts). -// Credits live on users.credits; pixels/points live on the users row too; -// duckets/diamonds live in users_currency keyed by (user_id, type). -const DUCKETS_TYPE = 0; -const DIAMONDS_TYPE = 5; - -function toInt(value: FormDataEntryValue | null, min = 0): number | null { - if (value == null) return null; - const raw = String(value).trim(); - if (raw === "") return null; - const n = Number(raw); - if (!Number.isFinite(n)) return null; - const i = Math.trunc(n); - return i < min ? min : i; -} - -/** - * Edit the SAFE website-managed fields of a users row (and the duckets/diamonds - * balances in users_currency). Never touches the password. Re-reads the staff - * user from the session and logs the action. emulator-owned users.id is Int. - */ -export async function updateUser(formData: FormData): Promise { - // Never trust the client: re-check USERS_EDIT inside the action. - const staff = await requirePermission(PERMS.USERS_EDIT); - - const userId = Number(formData.get("id")); - if (!Number.isInteger(userId) || userId <= 0) return; - - const existing = await prisma.user.findUnique({ - where: { id: userId }, - select: { id: true }, - }); - if (!existing) return; - - // users row — only existing, safe columns. - const mailRaw = String(formData.get("mail") ?? "") - .normalize("NFC") - .trim(); - const motto = String(formData.get("motto") ?? "") - .normalize("NFC") - .slice(0, 127); - const look = String(formData.get("look") ?? "") - .normalize("NFC") - .slice(0, 256); - const rank = toInt(formData.get("rank"), 1); - const credits = toInt(formData.get("credits"), 0); - const pixels = toInt(formData.get("pixels"), 0); - const points = toInt(formData.get("points"), 0); - - await prisma.user.update({ - where: { id: userId }, - data: { - mail: mailRaw === "" ? null : mailRaw.slice(0, 500), - motto, - look, - ...(rank != null ? { rank } : {}), - ...(credits != null ? { credits } : {}), - ...(pixels != null ? { pixels } : {}), - ...(points != null ? { points } : {}), - }, - }); - - // users_currency — set exact balances for duckets / diamonds. - const duckets = toInt(formData.get("duckets"), 0); - const diamonds = toInt(formData.get("diamonds"), 0); - if (duckets != null) { - await prisma.usersCurrency.upsert({ - where: { userId_type: { userId, type: DUCKETS_TYPE } }, - update: { amount: duckets }, - create: { userId, type: DUCKETS_TYPE, amount: duckets }, - }); - } - if (diamonds != null) { - await prisma.usersCurrency.upsert({ - where: { userId_type: { userId, type: DIAMONDS_TYPE } }, - update: { amount: diamonds }, - create: { userId, type: DIAMONDS_TYPE, amount: diamonds }, - }); - } - - await logStaffActivity({ - staffId: staff.id, - action: "user_edit", - description: `Edited account fields of user #${userId}`, - targetType: "user", - targetId: userId, - }); - - revalidatePath(`/admin/users/${userId}`); - revalidatePath(`/admin/users/edit/${userId}`); - redirect(`/admin/users/show/${userId}`); -} diff --git a/src/actions/admin-users.ts b/src/actions/admin-users.ts deleted file mode 100644 index 1b81c803..00000000 --- a/src/actions/admin-users.ts +++ /dev/null @@ -1,86 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { requirePermission, requirePermissionRateLimited } from "@/lib/admin/guard"; -import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; -import { rcon } from "@/lib/services/rcon"; -import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -const CURRENCIES: ReadonlySet = new Set([ - "credits", - "duckets", - "diamonds", - "points", -]); - -export async function giveCurrency(formData: FormData): Promise { - const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); - const userId = Number(formData.get("userId")); - const type = String(formData.get("type")); - const amount = Number(formData.get("amount")); - if (userId > 0 && amount > 0 && CURRENCIES.has(type)) { - await sendCurrency( - { rcon, db: prisma }, - userId, - type as CurrencyName, - amount, - ); - await logStaffActivity({ - staffId: staff.id, - action: "give_currency", - description: `Gave ${amount} ${type} to user #${userId}`, - targetType: "user", - targetId: userId, - }); - } - revalidatePath(`/admin/users/${userId}`); -} - -export async function setMotto(formData: FormData): Promise { - await requirePermission(PERMS.USERS_EDIT); - const userId = Number(formData.get("userId")); - const motto = String(formData.get("motto") ?? "") - .normalize("NFC") - .slice(0, 127); - if (userId > 0) { - await prisma.user.update({ where: { id: userId }, data: { motto } }); - await rcon.setMotto(userId, motto); - } - revalidatePath(`/admin/users/${userId}`); -} - -export async function setRank(formData: FormData): Promise { - const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); - const userId = Number(formData.get("userId")); - const rank = Number(formData.get("rank")); - if (userId > 0 && rank > 0) { - await prisma.user.update({ where: { id: userId }, data: { rank } }); - await rcon.setRank(userId, rank); - await logStaffActivity({ - staffId: staff.id, - action: "rank_change", - description: `Set rank of user #${userId} to ${rank}`, - targetType: "user", - targetId: userId, - }); - } - revalidatePath(`/admin/users/${userId}`); -} - -export async function alertUser(formData: FormData): Promise { - await requirePermission(PERMS.USERS_EDIT); - const userId = Number(formData.get("userId")); - const message = String(formData.get("message") ?? "") - .normalize("NFC") - .trim(); - if (userId > 0 && message) await rcon.alertUser(userId, message); -} - -export async function disconnectUser(formData: FormData): Promise { - await requirePermission(PERMS.USERS_EDIT); - const userId = Number(formData.get("userId")); - const username = String(formData.get("username") ?? "").normalize("NFC"); - if (userId > 0) await rcon.disconnectUser(userId, username); -} diff --git a/src/components/admin/confirm-action.tsx b/src/components/admin/confirm-action.tsx deleted file mode 100644 index 9a998748..00000000 --- a/src/components/admin/confirm-action.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; - -export function ConfirmAction({ - message, - action, - userId, - className, - style, - children, -}: { - message: string; - action: string; - userId: number; - className?: string; - style?: React.CSSProperties; - children: ReactNode; -}) { - return ( -
- - - -
- ); -} diff --git a/src/components/admin/page-header.tsx b/src/components/admin/page-header.tsx deleted file mode 100644 index dc1481a9..00000000 --- a/src/components/admin/page-header.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { LucideIcon } from "lucide-react"; - -interface AdminPageHeaderProps { - icon: LucideIcon; - title: string; - subtitle: string; -} - -export function AdminPageHeader({ - icon: Icon, - title, - subtitle, -}: AdminPageHeaderProps) { - return ( -
-
- -
-
-

- {title} -

-

- {subtitle} -

-
-
- ); -} diff --git a/src/components/motion-elements.tsx b/src/components/motion-elements.tsx deleted file mode 100644 index 6f3ce819..00000000 --- a/src/components/motion-elements.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; - -import type { HTMLMotionProps } from "framer-motion"; -import { motion } from "framer-motion"; -import { - fadeIn, - fadeInUp, - hoverLift, - hoverScale, - scaleIn, - staggerContainer, - staggerItem, -} from "@/lib/motion"; - -type DivProps = HTMLMotionProps<"div">; - -export function AnimatedSection({ children, className, ...props }: DivProps) { - return ( - - {children} - - ); -} - -export function AnimatedChild({ children, className, ...props }: DivProps) { - return ( - - {children} - - ); -} - -export function FadeIn({ children, className, ...props }: DivProps) { - return ( - - {children} - - ); -} - -export function FadeInUp({ children, className, ...props }: DivProps) { - return ( - - {children} - - ); -} - -export function ScaleIn({ children, className, ...props }: DivProps) { - return ( - - {children} - - ); -} - -export function HoverCard({ children, className, ...props }: DivProps) { - return ( - - {children} - - ); -} - -export function HoverButton({ children, className, ...props }: DivProps) { - return ( - - {children} - - ); -} diff --git a/src/lib/admin-operations-contract.test.ts b/src/lib/admin-operations-contract.test.ts index f954b190..f81a74bd 100644 --- a/src/lib/admin-operations-contract.test.ts +++ b/src/lib/admin-operations-contract.test.ts @@ -48,12 +48,10 @@ const ACTION_GATES: Array<[string, string]> = [ ["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"], ["catalog.ts", "PERMS.CATALOG_EDIT"], ["rooms.ts", "PERMS.ROOMS_EDIT"], - ["admin-users.ts", "PERMS.USERS_EDIT"], ["admin-vpn.ts", "PERMS.SETTINGS_EDIT"], ["admin-ads.ts", "PERMS.PAGES_EDIT"], ["admin-vouchers.ts", "PERMS.SHOP_EDIT"], ["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"], - ["admin-permissions.ts", "PERMS.PERMISSIONS_MANAGE"], ["commandocentrum.ts", "PERMS.RCON_EXECUTE"], ["translations.ts", "PERMS.SETTINGS_EDIT"], ]; diff --git a/src/lib/catalog-categories.ts b/src/lib/catalog-categories.ts deleted file mode 100644 index d44ed620..00000000 --- a/src/lib/catalog-categories.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./catalog-categories/index"; diff --git a/src/lib/catalog-categories/categories.ts b/src/lib/catalog-categories/categories.ts deleted file mode 100644 index 24b84ba1..00000000 --- a/src/lib/catalog-categories/categories.ts +++ /dev/null @@ -1,695 +0,0 @@ -/** - * Supreme catalog categories for organizing Habbo furniture. - * - * Each category belongs to a root: furni, costruire, animali, vestiti, or staff. - */ - -// ── Root Categories ────────────────────────────────────────────── - -export type RootCategory = - | "furni" - | "costruire" - | "animali" - | "vestiti" - | "staff"; - -export const ROOT_PAGES: Record< - RootCategory, - { label: string; labelEn: string; icon: number } -> = { - furni: { label: "Mobili", labelEn: "Furniture", icon: 263 }, - costruire: { label: "Costruire", labelEn: "Building", icon: 69991 }, - animali: { label: "Animali", labelEn: "Pets", icon: 8 }, - vestiti: { label: "Vestiti", labelEn: "Clothing", icon: 369 }, - staff: { label: "Staff", labelEn: "Staff", icon: 65 }, -}; - -// ── Supreme Categories ───────────────────────────────────────────── - -export interface SupremeCategory { - key: string; - label: string; - labelEn: string; - icon: number; - root: RootCategory; - keywords: string[]; - patterns: RegExp[]; - interactionTypes?: string[]; - layout: string; -} - -export const SUPREME_CATEGORIES: SupremeCategory[] = [ - // ─── Staff ─── - { - key: "staff_items", - label: "Staff", - labelEn: "Staff", - icon: 65, - root: "staff", - keywords: ["staff_", "admin_", "mod_", "moderator_"], - patterns: [/^staff_/, /^admin_/, /^mod_/], - layout: "default_3x3", - }, - // ─── Vestiti ─── - { - key: "fashion", - label: "Moda", - labelEn: "Fashion", - icon: 74, - root: "vestiti", - keywords: ["clothing_", "shirt_", "hat_", "dress_", "skirt_", "shoes_"], - patterns: [/^clothing_/, /^shirt_/, /^hat_/], - interactionTypes: ["clothing"], - layout: "default_3x3", - }, - // ─── Animali ─── - { - key: "pets", - label: "Animali", - labelEn: "Pets", - icon: 8, - root: "animali", - keywords: [ - "pet_", - "petfood", - "pet_toy", - "pet_nest", - "pet_breeding", - "horse_", - "monsterplant", - "waterbowl", - "petfood_", - ], - patterns: [ - /^pet_/, - /^petfood/, - /^pet_toy/, - /^pet_nest/, - /^pet_breeding/, - /^horse_/, - /^monsterplant_/, - /^waterbowl/, - ], - interactionTypes: [ - "pet_food", - "pet_toy", - "pet_breeding_box", - "pet_nest", - "pet_drink", - ], - layout: "pets", - }, - // ─── Furni ─── - { - key: "rare", - label: "Rari", - labelEn: "Rares", - icon: 145, - root: "furni", - keywords: ["rare_", "ultra_rare", "super_rare", "ltd_", "bonusrare"], - patterns: [/^rare_/, /^ultra_rare/, /^super_rare/, /^ltd_/, /^bonusrare/], - layout: "default_3x3", - }, - { - key: "wired", - label: "Wired", - labelEn: "Wired", - icon: 5, - root: "furni", - keywords: ["wf_", "wired_"], - patterns: [ - /^wf_/, - /^wired_/, - /^wf_act_/, - /^wf_cnd_/, - /^wf_trg_/, - /^wf_xtra/, - ], - interactionTypes: [ - "wired_condition", - "wired_trigger", - "wired_effect", - "wired_extra", - "wired_addon", - ], - layout: "default_3x3", - }, - { - key: "teleports", - label: "Teletrasporti", - labelEn: "Teleports", - icon: 120, - root: "furni", - keywords: ["teleport"], - patterns: [/^teleport_/, /_teleport$/], - interactionTypes: ["teleport", "teleport_tile"], - layout: "default_3x3", - }, - { - key: "rollers", - label: "Rulli", - labelEn: "Rollers", - icon: 222, - root: "furni", - keywords: ["roller_", "queue_tile"], - patterns: [/^roller_/, /^queue_tile/], - interactionTypes: ["roller"], - layout: "default_3x3", - }, - { - key: "games", - label: "Giochi", - labelEn: "Games", - icon: 78, - root: "furni", - keywords: [ - "freeze_", - "banzai", - "football_", - "snowb_", - "bb_gate", - "bb_patch", - "sporttrack", - ], - patterns: [ - /^bb_/, - /^freeze_/, - /^football_/, - /^snowb_/, - /^banzai_/, - /^battle_banzai/, - /^game_/, - /^sporttrack/, - ], - interactionTypes: [ - "battle_banzai_tile", - "battle_banzai_scoreboard", - "battle_banzai_puck", - "battle_banzai_sphere", - "battle_banzai_teleport", - "freeze_tile", - "freeze_exit", - "football_gate", - "football_goal", - "football", - ], - layout: "default_3x3", - }, - { - key: "music", - label: "Musica", - labelEn: "Music", - icon: 11, - root: "furni", - keywords: ["sound_", "jukebox", "trax_", "song_disk"], - patterns: [/^sound_/, /^jukebox/, /^trax_/, /^song_disk/], - interactionTypes: ["jukebox", "soundmachine", "music_disc"], - layout: "soundmachine", - }, - { - key: "trophies", - label: "Trofei", - labelEn: "Trophies", - icon: 221, - root: "furni", - keywords: ["trophy", "prizetrophy"], - patterns: [ - /^trophy_/, - /^prizetrophy/, - /^gold_trophy/, - /^silver_trophy/, - /^bronze_trophy/, - ], - interactionTypes: ["trophy"], - layout: "trophies", - }, - { - key: "effects", - label: "Effetti", - labelEn: "Effects", - icon: 61, - root: "furni", - keywords: ["effect_", "fx_", "avatar_effect"], - patterns: [/^effect_/, /^fx_/, /^avatar_effect/], - layout: "default_3x3", - }, - { - key: "credits", - label: "Crediti", - labelEn: "Credits", - icon: 27, - root: "furni", - keywords: ["cf_", "credit_furni", "deal_"], - patterns: [/^cf_/, /^credit_furni/, /^deal_/], - interactionTypes: ["exchange"], - layout: "default_3x3", - }, - { - key: "hc", - label: "Club Habbo", - labelEn: "Habbo Club", - icon: 172, - root: "furni", - keywords: [ - "hc_", - "club_", - "vip_", - "hcsohva", - "hcamme", - "edicehc", - "mocchamaster", - ], - patterns: [/^hc_/, /^club_/, /^vip_/], - layout: "default_3x3", - }, - { - key: "bots", - label: "Bot", - labelEn: "Bots", - icon: 36, - root: "furni", - keywords: ["bot_", "rentable_bot"], - patterns: [/^bot_/, /^rentable_bot/], - layout: "default_3x3", - }, - { - key: "groups", - label: "Gruppi", - labelEn: "Groups", - icon: 203, - root: "furni", - keywords: ["guild_", "group_"], - patterns: [/^guild_/, /^group_/], - interactionTypes: ["guild_gate", "guild_forum"], - layout: "guilds", - }, - // ─── Furniture Lines (alphabetical) ─── - { - key: "area", - label: "Area", - labelEn: "Area", - icon: 14, - root: "furni", - keywords: ["area_"], - patterns: [/^area_/], - layout: "default_3x3", - }, - { - key: "asian", - label: "Asiatico", - labelEn: "Asian", - icon: 15, - root: "furni", - keywords: ["asian_", "china_", "jade_", "jp_", "wooden_screen"], - patterns: [/^jp_/, /^asian_/, /^china_/, /^jade_/, /^wooden_screen/], - layout: "default_3x3", - }, - { - key: "bathroom", - label: "Bagno", - labelEn: "Bathroom", - icon: 17, - root: "furni", - keywords: ["bath_", "shower_", "toilet_"], - patterns: [/^bath_/, /^shower_/, /^toilet_/], - layout: "default_3x3", - }, - { - key: "candy", - label: "Caramelle", - labelEn: "Candy", - icon: 19, - root: "furni", - keywords: ["candy_", "sweet_"], - patterns: [/^candy_/, /^sweet_/], - layout: "default_3x3", - }, - { - key: "country", - label: "Country", - labelEn: "Country", - icon: 21, - root: "furni", - keywords: ["country_", "barn_", "hay_"], - patterns: [/^country_/, /^barn_/, /^hay_/], - layout: "default_3x3", - }, - { - key: "cubie", - label: "Cubie", - labelEn: "Cubie", - icon: 100, - root: "furni", - keywords: ["cubie_"], - patterns: [/^cubie_/], - layout: "default_3x3", - }, - { - key: "diner", - label: "Diner", - labelEn: "Diner", - icon: 204, - root: "furni", - keywords: ["diner_", "dsk_"], - patterns: [/^diner_/, /^dsk_/], - layout: "default_3x3", - }, - { - key: "executive", - label: "Executive", - labelEn: "Executive", - icon: 27, - root: "furni", - keywords: ["executive_", "office_"], - patterns: [/^exe_/, /^executive_/, /^office_/], - layout: "default_3x3", - }, - { - key: "gothic", - label: "Gotico", - labelEn: "Gothic", - icon: 30, - root: "furni", - keywords: ["gothic_", "goth_"], - patterns: [/^gothic_/, /^goth_/], - layout: "default_3x3", - }, - { - key: "iced", - label: "Ghiacciato", - labelEn: "Iced", - icon: 72, - root: "furni", - keywords: ["iced_", "norja"], - patterns: [/^iced_/, /^norja/], - layout: "default_3x3", - }, - { - key: "kitchen", - label: "Cucina", - labelEn: "Kitchen", - icon: 217, - root: "furni", - keywords: ["kitchen_", "fridge_", "stove_", "oven_", "sink_"], - patterns: [/^kitchen_/, /^fridge_/, /^stove_/, /^oven_/, /^sink_/], - layout: "default_3x3", - }, - { - key: "lodge", - label: "Baita", - labelEn: "Lodge", - icon: 37, - root: "furni", - keywords: ["lodge_"], - patterns: [/^lodge_/], - layout: "default_3x3", - }, - { - key: "maya", - label: "Maya", - labelEn: "Maya", - icon: 171, - root: "furni", - keywords: ["maya_"], - patterns: [/^maya_/], - layout: "default_3x3", - }, - { - key: "medieval", - label: "Medievale", - labelEn: "Medieval", - icon: 39, - root: "furni", - keywords: ["medieval", "castle_", "throne_", "knight_", "dragon_"], - patterns: [/^medieval_/, /^castle_/, /^throne_/], - layout: "default_3x3", - }, - { - key: "mode", - label: "Mode", - labelEn: "Mode", - icon: 39, - root: "furni", - keywords: ["polyfon", "bardesk"], - patterns: [/^bardesk/, /polyfon/], - layout: "default_3x3", - }, - { - key: "mystics", - label: "Mistici", - labelEn: "Mystics", - icon: 185, - root: "furni", - keywords: ["mystic"], - patterns: [/^mystic/], - layout: "default_3x3", - }, - { - key: "plastic", - label: "Plastica", - labelEn: "Plastic", - icon: 46, - root: "furni", - keywords: ["plasto", "plasty", "plastic_"], - patterns: [/plasto/, /plasty/, /^plastic_/], - layout: "default_3x3", - }, - { - key: "pura", - label: "Pura", - labelEn: "Pura", - icon: 48, - root: "furni", - keywords: ["pura_"], - patterns: [/^pura_/], - layout: "default_3x3", - }, - { - key: "relax", - label: "Relax", - labelEn: "Relax", - icon: 49, - root: "furni", - keywords: ["relax_"], - patterns: [/^relax_/], - layout: "default_3x3", - }, - { - key: "romantique", - label: "Romantique", - labelEn: "Romantique", - icon: 50, - root: "furni", - keywords: ["romantique", "romant_"], - patterns: [/^romantique/, /^romant_/], - layout: "default_3x3", - }, - { - key: "scifi", - label: "Sci-Fi", - labelEn: "Sci-Fi", - icon: 53, - root: "furni", - keywords: ["scifi"], - patterns: [/^scifi/], - layout: "default_3x3", - }, - { - key: "steampunk", - label: "Steampunk", - labelEn: "Steampunk", - icon: 180, - root: "furni", - keywords: ["steampunk"], - patterns: [/^steampunk/], - layout: "default_3x3", - }, - { - key: "tropical", - label: "Tropicale", - labelEn: "Tropical", - icon: 38, - root: "furni", - keywords: ["tropic", "tropical", "palm_", "tiki_", "coconut"], - patterns: [/^tropic_/, /^tropical_/, /^palm_/, /^tiki_/], - layout: "default_3x3", - }, - { - key: "usva", - label: "USVA", - labelEn: "USVA", - icon: 129, - root: "furni", - keywords: ["usva_"], - patterns: [/^usva_/], - layout: "default_3x3", - }, - { - key: "waasa", - label: "Waasa", - labelEn: "Waasa", - icon: 103, - root: "furni", - keywords: ["waasa_"], - patterns: [/^waasa_/], - layout: "default_3x3", - }, - // ─── Seasonal ─── - { - key: "christmas", - label: "Natale", - labelEn: "Christmas", - icon: 168, - root: "furni", - keywords: [ - "xmas", - "christmas", - "santa", - "snowman", - "advent", - "jouluank", - "joulutahti", - "hyasintti", - "wcandle", - ], - patterns: [/^xmas_/, /^christmas_/, /^xmas\d/, /^santai/, /^snow_/], - layout: "default_3x3", - }, - { - key: "halloween", - label: "Halloween", - labelEn: "Halloween", - icon: 34, - root: "furni", - keywords: [ - "halloween", - "hween", - "ghost_", - "skull_", - "pumpkin", - "zombie", - "skeleton", - "spider_", - "witch_", - "deadduck", - "kallo", - ], - patterns: [/^hween_/, /^halloween_/, /^ghost_/, /^skull_/, /^pumpkin/], - layout: "default_3x3", - }, - { - key: "valentine", - label: "San Valentino", - labelEn: "Valentine", - icon: 62, - root: "furni", - keywords: ["valentine", "heart_", "love_", "cupid", "cupido"], - patterns: [/^val_/, /^valentine_/, /^heart_/, /^love_/], - layout: "default_3x3", - }, - { - key: "easter", - label: "Pasqua", - labelEn: "Easter", - icon: 181, - root: "furni", - keywords: ["easter", "bunny_", "easterduck"], - patterns: [/^easter_/, /^egg_/], - layout: "default_3x3", - }, - // ─── Lighting ─── - { - key: "lighting", - label: "Illuminazione", - labelEn: "Lighting", - icon: 115, - root: "furni", - keywords: ["lamp_", "light_", "candle_", "torch_", "neon_"], - patterns: [/^lamp_/, /^light_/, /^candle_/, /^torch_/, /^neon_/], - interactionTypes: ["dimmer"], - layout: "default_3x3", - }, - // ─── Costruire ─── - { - key: "builders_club", - label: "Builders Club", - labelEn: "Builders Club", - icon: 28, - root: "costruire", - keywords: ["bc_"], - patterns: [/^bc_/], - layout: "default_3x3", - }, - { - key: "walls", - label: "Pareti", - labelEn: "Walls", - icon: 122, - root: "costruire", - keywords: ["wallpaper", "poster_"], - patterns: [/^wall_/, /^wallpaper/, /^poster_/], - layout: "default_3x3", - }, - { - key: "floors", - label: "Pavimenti", - labelEn: "Floors", - icon: 41, - root: "costruire", - keywords: ["carpet_", "rug_", "doormat", "floortile"], - patterns: [/^floor_/, /^carpet_/, /^rug_/, /^doormat/, /^floortile/], - layout: "default_3x3", - }, - { - key: "landscapes", - label: "Paesaggi", - labelEn: "Landscapes", - icon: 206, - root: "costruire", - keywords: ["landscape_"], - patterns: [/^landscape_/], - layout: "default_3x3", - }, - { - key: "windows", - label: "Finestre", - labelEn: "Windows", - icon: 218, - root: "costruire", - keywords: ["window_", "curtain_"], - patterns: [/^window_/, /^curtain_/], - layout: "default_3x3", - }, - { - key: "doors_gates", - label: "Porte e Cancelli", - labelEn: "Doors & Gates", - icon: 55, - root: "costruire", - keywords: ["door_", "gate_", "marquee"], - patterns: [/^door_/, /^gate_/, /^marquee/], - interactionTypes: ["gate"], - layout: "default_3x3", - }, - { - key: "plants", - label: "Piante", - labelEn: "Plants", - icon: 220, - root: "costruire", - keywords: ["plant_", "tree_", "flower_", "bush_", "cactus"], - patterns: [/^plant_/, /^tree_/, /^flower_/, /^bush_/, /^cactus/], - layout: "default_3x3", - }, - // Fallback - { - key: "uncategorized", - label: "Non Categorizzati", - labelEn: "Uncategorized", - icon: 1, - root: "furni", - keywords: [], - patterns: [/.*/], - layout: "default_3x3", - }, -]; diff --git a/src/lib/catalog-categories/eras.ts b/src/lib/catalog-categories/eras.ts deleted file mode 100644 index 7bc1f89c..00000000 --- a/src/lib/catalog-categories/eras.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Release eras for organizing Habbo furniture by revision number. - */ - -// ── Release Eras ─────────────────────────────────────────────────── - -export interface ReleaseEra { - key: string; - label: string; - labelEn: string; - revisionMin: number; - revisionMax: number; -} - -export const RELEASE_ERAS: ReleaseEra[] = [ - { - key: "classic", - label: "Classici 2000-2003", - labelEn: "Classic 2000-2003", - revisionMin: 0, - revisionMax: 19999, - }, - { - key: "mid", - label: "Era HC 2004-2006", - labelEn: "HC Era 2004-2006", - revisionMin: 20000, - revisionMax: 39999, - }, - { - key: "modern", - label: "Moderni 2007-2009", - labelEn: "Modern 2007-2009", - revisionMin: 40000, - revisionMax: 59999, - }, - { - key: "recent", - label: "Recenti 2010-2012", - labelEn: "Recent 2010-2012", - revisionMin: 60000, - revisionMax: 79999, - }, - { - key: "latest", - label: "Ultimi 2013+", - labelEn: "Latest 2013+", - revisionMin: 80000, - revisionMax: Infinity, - }, -]; - -/** - * Match a revision number to a release era. - */ -export function matchEra(revision: number): ReleaseEra { - for (const era of RELEASE_ERAS) { - if (revision >= era.revisionMin && revision <= era.revisionMax) { - return era; - } - } - return RELEASE_ERAS[RELEASE_ERAS.length - 1]; -} diff --git a/src/lib/catalog-categories/index.ts b/src/lib/catalog-categories/index.ts deleted file mode 100644 index e2f011cf..00000000 --- a/src/lib/catalog-categories/index.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Supreme catalog categories + release eras for organizing Habbo furniture. - * - * Matching priority (first match wins within each level): - * 1. Interaction type (most specific) - * 2. Keywords anywhere in item name (normalized, *N stripped) - * 3. Regex prefix patterns (on normalized name) - * 4. Auto-prefix grouping (handled in route.ts) - * 5. Uncategorized fallback - * - * Each category belongs to a root: furni, costruire, animali, vestiti, or staff. - */ - -export * from "./categories"; -export * from "./eras"; - -import { SUPREME_CATEGORIES, type SupremeCategory } from "./categories"; - -// ── Name Normalization ────────────────────────────────────────── - -/** - * Normalize a Habbo item classname for matching. - * Strips the *N color suffix (e.g. "chair_plasto*5" -> "chair_plasto") - * and lowercases the result. - */ -export function normalizeName(itemName: string): string { - return itemName.replace(/\*\d+$/, "").toLowerCase(); -} - -// ── Category Detection ────────────────────────────────────────── - -export const UNCATEGORIZED_KEY = "uncategorized"; -export const AUTO_PREFIX_MIN_ITEMS = 5; - -/** - * Find the supreme category for an item by classname and interaction type. - * - * Priority: interaction type -> keywords (anywhere in normalized name) -> regex patterns. - * First match wins within each priority level. - * Item names are normalized (strip *N suffix, lowercase) before matching. - */ -export function matchCategory( - itemName: string, - interactionType?: string, -): SupremeCategory { - const lower = normalizeName(itemName); - - // Pass 1: Interaction type (most specific) - for (const cat of SUPREME_CATEGORIES) { - if (interactionType && cat.interactionTypes?.includes(interactionType)) { - return cat; - } - } - - // Pass 2: Keywords anywhere in name (user priority) - for (const cat of SUPREME_CATEGORIES) { - if (cat.key === UNCATEGORIZED_KEY) continue; - for (const kw of cat.keywords) { - if (lower.includes(kw)) return cat; - } - } - - // Pass 3: Regex prefix patterns (fallback) - for (const cat of SUPREME_CATEGORIES) { - if (cat.key === UNCATEGORIZED_KEY) continue; - for (const pattern of cat.patterns) { - if (pattern.test(lower)) return cat; - } - } - - return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1]; -} - -// ── Auto-Prefix Grouping ──────────────────────────────────────────── - -export interface AutoDetectedCategory { - key: string; - prefix: string; - label: string; - labelEn: string; - icon: number; - layout: string; - autoDetected: true; -} - -/** - * Extract prefix from an item name for auto-grouping. - * - Strips *N color suffix first ("chair_plasto*5" -> "chair_plasto") - * - If name contains '_', take everything before the first '_' - * - If no underscore, take the alphabetic prefix (letters before first digit) - * - Lowercase the result; return null if prefix is empty or 1 character - */ -export function extractPrefix(itemName: string): string | null { - const normalized = normalizeName(itemName); - const idx = normalized.indexOf("_"); - if (idx > 1) return normalized.substring(0, idx); - const match = normalized.match(/^([a-z]{2,})/); - return match ? match[1] : null; -} diff --git a/src/lib/format-date.ts b/src/lib/format-date.ts deleted file mode 100644 index 1a4a35d1..00000000 --- a/src/lib/format-date.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Format a Date as 'YYYY-MM-DD HH:MM:SS' — used across admin and radio pages. */ -export function formatDateTime(d: Date | null | undefined): string { - return d ? d.toISOString().slice(0, 19).replace("T", " ") : ""; -} - -/** Format a Date as 'YYYY-MM-DD' — used across radio list pages. */ -export function formatDate(d: Date | null | undefined): string { - return d ? d.toISOString().slice(0, 10) : ""; -} diff --git a/src/lib/foundation/database.ts b/src/lib/foundation/database.ts deleted file mode 100644 index 05749da4..00000000 --- a/src/lib/foundation/database.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { PrismaMariaDb } from "@prisma/adapter-mariadb"; -import { env } from "@/env"; -import { PrismaClient } from "@/generated/prisma/client"; -import { logger } from "@/lib/logger"; -import { DatabaseError } from "./errors"; -import { getRequestId } from "./request-context"; - -const globalForDb = globalThis as unknown as { _db?: DbService }; - -interface HealthStatus { - ok: boolean; - latencyMs: number; - poolSize: number; - activeQueries: number; - error?: string; -} - -export class DbService { - private readonly client: PrismaClient; - private lastHealthCheck = 0; - private healthCache: HealthStatus | null = null; - private readonly healthTtlMs = 10_000; - - constructor() { - const url = new URL(env.DATABASE_URL); - const adapter = new PrismaMariaDb({ - host: url.hostname, - port: Number(url.port) || 3306, - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - connectionLimit: env.DATABASE_POOL_SIZE, - connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, - acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, - idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, - }); - - this.client = new PrismaClient({ - adapter, - log: - env.NODE_ENV === "development" - ? [ - { emit: "event", level: "query" }, - { emit: "event", level: "error" }, - ] - : [{ emit: "event", level: "error" }], - }); - - if (env.NODE_ENV === "development") { - this.client.$on("query" as never, (e: unknown) => { - const ev = e as { query: string; duration: number }; - logger.debug("DB query", { - query: ev.query.slice(0, 200), - durationMs: ev.duration, - requestId: getRequestId(), - }); - }); - } - - this.client.$on("error" as never, (e: unknown) => { - const ev = e as { message: string }; - logger.error("DB error", { - message: ev.message, - requestId: getRequestId(), - }); - }); - } - - get prisma(): PrismaClient { - return this.client; - } - - async health(): Promise { - const now = Date.now(); - if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) { - return this.healthCache; - } - - const start = performance.now(); - try { - await this.client.$queryRaw`SELECT 1`; - const latencyMs = Math.round(performance.now() - start); - this.healthCache = { - ok: true, - latencyMs, - poolSize: env.DATABASE_POOL_SIZE, - activeQueries: 0, - }; - this.lastHealthCheck = now; - return this.healthCache; - } catch (cause) { - const latencyMs = Math.round(performance.now() - start); - const message = - cause instanceof Error ? cause.message : "Unknown database error"; - this.healthCache = { - ok: false, - latencyMs, - poolSize: env.DATABASE_POOL_SIZE, - activeQueries: 0, - error: message, - }; - this.lastHealthCheck = now; - return this.healthCache; - } - } - - async execute(fn: (client: PrismaClient) => Promise): Promise { - try { - return await fn(this.client); - } catch (cause) { - throw new DatabaseError("Query failed", cause); - } - } - - async transaction( - fn: ( - tx: Omit< - PrismaClient, - "$connect" | "$disconnect" | "$on" | "$use" | "$extends" - >, - ) => Promise, - ): Promise { - try { - return await this.client.$transaction(fn); - } catch (cause) { - throw new DatabaseError("Transaction failed", cause); - } - } - - async rawQuery( - strings: TemplateStringsArray, - ...values: unknown[] - ): Promise { - try { - return await this.client.$queryRaw(strings, ...values); - } catch (cause) { - throw new DatabaseError("Raw query failed", cause); - } - } - - /** - * Execute a raw SQL string with parameterized ? placeholders. - * Named "Unsafe" because the caller is responsible for using ? placeholders - * and never interpolating user input directly into the query string. - */ - async executeRawUnsafe(query: string, ...values: unknown[]): Promise { - try { - return await this.client.$executeRawUnsafe(query, ...values); - } catch (cause) { - throw new DatabaseError("Execute raw failed", cause); - } - } -} - -export const db = globalForDb._db ?? new DbService(); -if (env.NODE_ENV !== "production") globalForDb._db = db; diff --git a/src/lib/foundation/index.ts b/src/lib/foundation/index.ts deleted file mode 100644 index d78515b3..00000000 --- a/src/lib/foundation/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -export { - actionError, - actionOk, - adminAction, - authAction, - handleActionError, -} from "./action"; - -export { DbService, db } from "./database"; -export { - ConflictError, - DatabaseError, - DomainError, - ForbiddenError, - NotFoundError, - RateLimitError, - UnauthorizedError, - ValidationError, -} from "./errors"; -export { - addSecurityHeaders, - chain, - protectAdminRoutes, - withRequestContext, -} from "./middleware"; - -export { - createStore, - elapsed, - getClientIp, - getRequestId, - getRequestStore, - runWithStore, - setContextUserId, -} from "./request-context"; -export { - canonicalize, - canonicalizeFormData, - canonicalizeFormValue, - extractClientIpAsync, - redirectSafe, - safeRedirect, - sanitizeFilename, - setCsrfCookie, - validateCsrfToken, -} from "./security"; -export type { - ActionContext, - ActionFailure, - ActionResult, - ActionSuccess, - AdminActionContext, - AppSession, - IpAddress, - PaginatedQuery, - PaginatedResult, - PermissionSet, - RankId, - RequestContext, - RequestId, - SessionUser, - UserId, -} from "./types"; -export { - bigIntString, - boolString, - buildSearchQuery, - email, - hexColor, - idParam, - look, - nonNegativeInt, - pagination, - password, - positiveInt, - slug, - url, - username, -} from "./validation"; diff --git a/src/lib/foundation/middleware.ts b/src/lib/foundation/middleware.ts deleted file mode 100644 index 36ad0d1e..00000000 --- a/src/lib/foundation/middleware.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { logger } from "@/lib/logger"; -import { createStore, getRequestId, runWithStore } from "./request-context"; -import { extractClientIpAsync, safeRedirect } from "./security"; - -type MiddlewareHandler = (req: NextRequest) => Promise; - -export function chain(...handlers: MiddlewareHandler[]): MiddlewareHandler { - return async (req: NextRequest) => { - for (const handler of handlers) { - const result = await handler(req); - if (result) return result; - } - return NextResponse.next(); - }; -} - -export function withRequestContext( - handler: MiddlewareHandler, -): MiddlewareHandler { - return async (req: NextRequest) => { - const ip = await extractClientIpAsync(); - const store = createStore(ip); - return runWithStore(store, async () => { - const start = Date.now(); - try { - const result = await handler(req); - const duration = Date.now() - start; - logger.info("Request completed", { - method: req.method, - path: req.nextUrl.pathname, - status: result?.status ?? 200, - durationMs: duration, - requestId: getRequestId(), - }); - return result; - } catch (error) { - const duration = Date.now() - start; - logger.error("Request failed", { - method: req.method, - path: req.nextUrl.pathname, - durationMs: duration, - requestId: getRequestId(), - error: error instanceof Error ? error.message : String(error), - }); - const safeUrl = new URL( - safeRedirect(req.nextUrl.pathname, "/"), - req.url, - ); - return NextResponse.redirect(safeUrl); - } - }); - }; -} - -export function protectAdminRoutes(req: NextRequest): NextResponse | null { - const { pathname } = req.nextUrl; - - if (!pathname.startsWith("/admin")) return null; - - const authToken = - req.cookies.get("next-auth.session-token")?.value ?? - req.cookies.get("__Secure-next-auth.session-token")?.value; - - if (!authToken) { - const loginUrl = new URL("/login", req.url); - loginUrl.searchParams.set("callbackUrl", pathname); - return NextResponse.redirect(loginUrl); - } - - return null; -} - -export function addSecurityHeaders(req: NextRequest): NextResponse | null { - if (req.method === "OPTIONS") return null; - - const response = NextResponse.next(); - const csp = [ - "default-src 'self'", - "script-src 'self' 'unsafe-eval' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: https: http:", - "font-src 'self' https:", - "connect-src 'self' https: wss:", - "frame-src 'self'", - "object-src 'none'", - "base-uri 'self'", - "form-action 'self'", - ].join("; "); - - response.headers.set("Content-Security-Policy", csp); - response.headers.set("X-Content-Type-Options", "nosniff"); - response.headers.set("X-Frame-Options", "DENY"); - response.headers.set("X-XSS-Protection", "0"); - response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - response.headers.set( - "Permissions-Policy", - "camera=(), microphone=(), geolocation=()", - ); - - return response; -} diff --git a/src/lib/foundation/validation.ts b/src/lib/foundation/validation.ts deleted file mode 100644 index 9fd0a93a..00000000 --- a/src/lib/foundation/validation.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { z } from "zod"; - -const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/; - -export const username = z - .string() - .trim() - .min(1, "Username is required") - .max(32, "Username must be at most 32 characters") - .regex( - USERNAME_RE, - "Username may only contain letters, numbers, hyphens, underscores, and dots", - ) - .transform((v) => v.normalize("NFC")); - -export const password = z - .string() - .min(8, "Password must be at least 8 characters") - .max(128, "Password must be at most 128 characters"); - -export const email = z - .string() - .trim() - .email("Invalid email address") - .max(255, "Email must be at most 255 characters") - .transform((v) => v.normalize("NFC").toLowerCase()); - -const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i; -const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; - -export const hexColor = z - .string() - .length(7, "Must be exactly 7 characters (e.g. #ff0000)") - .regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)"); - -export const slug = z - .string() - .trim() - .min(1, "Slug is required") - .max(64, "Slug must be at most 64 characters") - .regex( - SLUG_RE, - "Slug must be lowercase alphanumeric with hyphens only between characters", - ) - .refine( - (v) => !v.startsWith("-") && !v.endsWith("-"), - "Slug must not start or end with a hyphen", - ) - .transform((v) => v.normalize("NFC")); - -export const url = z - .string() - .url("Invalid URL") - .max(2048, "URL must be at most 2048 characters"); - -export const look = z - .string() - .max(512, "Look string must be at most 512 characters") - .regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format") - .optional(); - -export const positiveInt = z - .number() - .int("Must be a whole number") - .positive("Must be positive"); - -export const nonNegativeInt = z - .number() - .int("Must be a whole number") - .nonnegative("Must not be negative"); - -export const bigIntString = z - .string() - .regex(/^\d+$/, "Must be a numeric string") - .transform(BigInt); - -export const idParam = z - .string() - .regex(/^\d+$/, "ID must be numeric") - .transform(Number); - -export const pagination = z.object({ - page: z.coerce.number().int().positive().default(1), - perPage: z.coerce.number().int().min(1).max(250).default(50), - sort: z.string().optional(), - order: z.enum(["asc", "desc"]).optional(), - search: z.string().max(256).optional(), -}); - -export const boolString = z - .string() - .transform((v) => v === "true" || v === "1") - .or(z.boolean()); - -export function buildSearchQuery(fields: string[], search: string | undefined) { - if (!search?.trim()) return undefined; - const sanitized = search.normalize("NFC").trim().slice(0, 256); - return fields.map((f) => ({ [f]: { contains: sanitized } })); -} diff --git a/src/lib/services/imager/avatar-renderer.ts b/src/lib/services/imager/avatar-renderer.ts deleted file mode 100644 index 8e44df52..00000000 --- a/src/lib/services/imager/avatar-renderer.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Self-hosted avatar imager — Phase 1. - * - * Pipeline (cascade): - * 1. In-memory LRU cache → instant - * 2. Disk cache → fast - * 3. In-flight dedup → coalesce thundering herd - * 4. Upstream Habbo proxy → save to disk + memory - * 5. Transparent fallback → never break the UI - */ - -import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { siteSettings } from "@/lib/services/site-settings"; -import { InFlight, LruCache, NegativeCache } from "./memory-cache"; - -const CACHE_DIR = join(process.cwd(), "public", "cache", "avatars"); -const DEFAULT_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage"; - -const MEM_CACHE_CAPACITY = 500; -const NEGATIVE_CACHE_TTL_MS = 60_000; -const UPSTREAM_TIMEOUT_MS = 10_000; - -// 1×1 transparent PNG (67 bytes) — served when both local and upstream fail. -const TRANSPARENT_PNG = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=", - "base64", -); - -export interface AvatarRenderOptions { - figure: string; - size?: "s" | "m" | "l"; - direction?: number; - headDirection?: number; - headOnly?: boolean; - gesture?: string; - action?: string; -} - -export interface AvatarRenderResult { - buffer: Buffer; - etag: string; - source: "memory" | "disk" | "upstream" | "fallback"; -} - -const memCache = new LruCache(MEM_CACHE_CAPACITY); -const inFlight = new InFlight(); -const negativeCache = new NegativeCache(NEGATIVE_CACHE_TTL_MS); - -function getCacheKey(opts: AvatarRenderOptions): string { - const raw = [ - opts.figure, - opts.size ?? "m", - opts.direction ?? 2, - opts.headDirection ?? 3, - opts.headOnly ? "h" : "f", - opts.gesture ?? "", - opts.action ?? "", - ].join("_"); - return createHash("md5").update(raw).digest("hex"); -} - -function getCachePath(key: string): string { - return join(CACHE_DIR, key.slice(0, 2), `${key}.png`); -} - -function makeEtag(key: string): string { - return `W/"${key}"`; -} - -function saveToCache(cachePath: string, data: Buffer): void { - try { - const dir = dirname(cachePath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(cachePath, data); - } catch { - /* non-fatal */ - } -} - -function isPng(buf: Buffer): boolean { - return ( - buf.length > 8 && - buf[0] === 0x89 && - buf[1] === 0x50 && - buf[2] === 0x4e && - buf[3] === 0x47 - ); -} - -async function resolveUpstreamBase(): Promise { - const fromEnv = process.env.IMAGING_UPSTREAM_URL?.trim(); - if (fromEnv) return fromEnv.replace(/\/+$/, ""); - const fromSettings = - (await siteSettings.get("habbo_imaging_url", DEFAULT_UPSTREAM)) ?? - DEFAULT_UPSTREAM; - return fromSettings.replace(/\/+$/, "") || DEFAULT_UPSTREAM; -} - -async function fetchUpstream(opts: AvatarRenderOptions): Promise { - try { - const params = new URLSearchParams({ - figure: opts.figure, - direction: String(opts.direction ?? 2), - head_direction: String(opts.headDirection ?? 3), - size: opts.size ?? "m", - }); - if (opts.headOnly) params.set("headonly", "1"); - if (opts.gesture) params.set("gesture", opts.gesture); - if (opts.action) params.set("action", opts.action); - - const base = await resolveUpstreamBase(); - const url = `${base}?${params.toString()}`; - const res = await fetch(url, { - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }, - }); - if (!res.ok) return null; - - const buffer = Buffer.from(await res.arrayBuffer()); - return isPng(buffer) ? buffer : null; - } catch { - return null; - } -} - -/** - * Get an avatar image. Always returns a result — never throws, never returns - * null. Worst case is a 1×1 transparent PNG so the UI never shows broken - * images. - */ -export async function renderAvatar( - opts: AvatarRenderOptions, -): Promise { - const key = getCacheKey(opts); - const etag = makeEtag(key); - - const fromMem = memCache.get(key); - if (fromMem) return { buffer: fromMem, etag, source: "memory" }; - - const cachePath = getCachePath(key); - if (existsSync(cachePath)) { - try { - const buf = readFileSync(cachePath); - memCache.set(key, buf); - return { buffer: buf, etag, source: "disk" }; - } catch { - /* fall through to fetch */ - } - } - - if (negativeCache.isMarked(key)) { - return { buffer: TRANSPARENT_PNG, etag, source: "fallback" }; - } - - return inFlight.dedup(key, async () => { - if (existsSync(cachePath)) { - try { - const buf = readFileSync(cachePath); - memCache.set(key, buf); - return { buffer: buf, etag, source: "disk" }; - } catch { - /* fall through */ - } - } - - const useUpstream = - (await siteSettings.get("imaging_use_habbo_fallback", "1")) === "1"; - if (useUpstream) { - const upstream = await fetchUpstream(opts); - if (upstream) { - saveToCache(cachePath, upstream); - memCache.set(key, upstream); - return { buffer: upstream, etag, source: "upstream" }; - } - } - - negativeCache.mark(key); - return { buffer: TRANSPARENT_PNG, etag, source: "fallback" }; - }); -} - -export function getImagerStats() { - return { - memCacheSize: memCache.size, - memCacheCapacity: MEM_CACHE_CAPACITY, - cacheDir: CACHE_DIR, - }; -} diff --git a/src/lib/services/imager/memory-cache.ts b/src/lib/services/imager/memory-cache.ts deleted file mode 100644 index c65afe49..00000000 --- a/src/lib/services/imager/memory-cache.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Tiny LRU + in-flight dedup utilities for the avatar imager. - * Pure in-house, zero deps. Map preserves insertion order, so we - * implement LRU by deleting + re-inserting on access. - */ - -export class LruCache { - private map = new Map(); - - constructor(private readonly capacity: number) {} - - get(key: string): V | undefined { - const v = this.map.get(key); - if (v === undefined) return undefined; - this.map.delete(key); - this.map.set(key, v); - return v; - } - - set(key: string, value: V): void { - if (this.map.has(key)) this.map.delete(key); - this.map.set(key, value); - if (this.map.size > this.capacity) { - const oldest = this.map.keys().next().value; - if (oldest !== undefined) this.map.delete(oldest); - } - } - - has(key: string): boolean { - return this.map.has(key); - } - - delete(key: string): void { - this.map.delete(key); - } - - clear(): void { - this.map.clear(); - } - - get size(): number { - return this.map.size; - } -} - -/** - * In-flight request deduplication. If N callers ask for the same key - * concurrently, only the first one runs `loader` and the rest await - * the same Promise. - */ -export class InFlight { - private pending = new Map>(); - - async dedup(key: string, loader: () => Promise): Promise { - const existing = this.pending.get(key); - if (existing) return existing; - const p = loader().finally(() => { - this.pending.delete(key); - }); - this.pending.set(key, p); - return p; - } -} - -/** - * Negative cache: marks a key as "known to fail" for `ttlMs` so we don't - * hammer the upstream / re-attempt expensive renders for broken figures. - */ -export class NegativeCache { - private map = new Map(); - - constructor(private readonly ttlMs: number) {} - - isMarked(key: string): boolean { - const exp = this.map.get(key); - if (exp === undefined) return false; - if (Date.now() > exp) { - this.map.delete(key); - return false; - } - return true; - } - - mark(key: string): void { - this.map.set(key, Date.now() + this.ttlMs); - if (this.map.size > 1000) { - const now = Date.now(); - for (const [k, exp] of this.map) { - if (now > exp) this.map.delete(k); - } - } - } - - clear(): void { - this.map.clear(); - } -} diff --git a/src/lib/services/nitro-assets.ts b/src/lib/services/nitro-assets.ts deleted file mode 100644 index aac7af39..00000000 --- a/src/lib/services/nitro-assets.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { matchCategory, normalizeName } from "@/lib/catalog-categories"; -import { getAncestors } from "@/lib/services/catalog-tree"; -import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; - -/** - * Categorize a file using matchCategory logic. - * Returns the category key. - */ -export function categorizeFile(filename: string): string { - const name = filename.replace(/\.nitro$/, ""); - const cat = matchCategory(normalizeName(name)); - return cat.key; -} - -/** - * Build a nested folder path from catalog page ancestors. - * Example: page "HC Rares" under "Rares" under "Furni" → "furni/rares/hc-rares" - * Includes the root page in the path. - */ -export async function buildCatalogPath(pageId: number): Promise { - const ancestors = await getAncestors(pageId); - if (ancestors.length === 0) return "uncategorized"; - - return ancestors.map((a) => slugifyPath(a.caption)).join("/"); -} - -/** Sanitize a catalog page name into a valid folder segment */ -export function slugifyPath(name: string): string { - return ( - name - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "uncategorized" - ); -} - -/** - * Scan the flat furniture folder for .nitro files. - */ -export async function scanFlatFolder(): Promise { - try { - const { nitroDir } = await getFurniAssetDirs(); - const entries = await fs.readdir(nitroDir); - return entries.filter((e) => e.endsWith(".nitro")); - } catch { - return []; - } -} - -/** - * Scan ALL .nitro files recursively (flat + subfolders). - * Returns a Set of classnames (filename without .nitro extension). - */ -export async function scanAllNitroFiles(): Promise> { - try { - const { nitroDir } = await getFurniAssetDirs(); - const entries = await fs.readdir(nitroDir, { recursive: true }); - const classnames = new Set(); - for (const entry of entries) { - const name = typeof entry === "string" ? entry : String(entry); - if (name.endsWith(".nitro")) { - const basename = path.basename(name, ".nitro"); - classnames.add(basename); - } - } - return classnames; - } catch { - return new Set(); - } -}