Compare commits
13
Commits
c3c566fa2f
...
2c26e4bebb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c26e4bebb | ||
|
|
e31f6d985c | ||
|
|
7ef5038a9e | ||
|
|
f89b8a6adf | ||
|
|
3b6ebe7bdc | ||
|
|
a810fba0ae | ||
|
|
27a3652a79 | ||
|
|
3d89e108f3 | ||
|
|
0f35bc8529 | ||
|
|
1bca9657fa | ||
|
|
361ff56d65 | ||
|
|
e76c530e4f | ||
|
|
e5ec3c1f06 |
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
-- 0020_performance_indexes.sql
|
||||||
|
-- Adds indexes for the hot CMS read paths (shared DB with the Arcturus
|
||||||
|
-- emulator — additive only, no schema changes to emulator-owned columns).
|
||||||
|
--
|
||||||
|
-- users.credits → credits leaderboard (ORDER BY credits DESC LIMIT 20)
|
||||||
|
-- users_currency(type, amount) → duckets/diamonds leaderboard (WHERE type=? ORDER BY amount DESC LIMIT 20)
|
||||||
|
-- users_settings.respects_received → respects leaderboard (ORDER BY respects_received DESC LIMIT 20)
|
||||||
|
-- camera_web.timestamp → homepage recent photos (ORDER BY timestamp DESC LIMIT 4)
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_users_credits` ON `users` (`credits`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_users_currency_type_amount` ON `users_currency` (`type`, `amount`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_users_settings_respects_received` ON `users_settings` (`respects_received`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_camera_web_timestamp` ON `camera_web` (`timestamp`);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 0021_add_messenger_offline_user_id_index.sql
|
||||||
|
-- The /me dashboard counts unread offline messages with
|
||||||
|
-- `WHERE user_id = ?`; messenger_offline previously had no index there.
|
||||||
|
CREATE INDEX IF NOT EXISTS `idx_messenger_offline_user_id` ON `messenger_offline` (`user_id`);
|
||||||
@@ -2,7 +2,7 @@ module.exports = {
|
|||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "epicnextcms",
|
name: "epicnextcms",
|
||||||
script: "node_modules/next/dist/bin/next",
|
script: "pnpm",
|
||||||
args: "start",
|
args: "start",
|
||||||
instances: "max",
|
instances: "max",
|
||||||
exec_mode: "cluster",
|
exec_mode: "cluster",
|
||||||
@@ -10,7 +10,7 @@ module.exports = {
|
|||||||
max_memory_restart: "1G",
|
max_memory_restart: "1G",
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: 3000
|
PORT: 3002
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-2
@@ -52,8 +52,6 @@ const nextConfig: NextConfig = {
|
|||||||
ignoreBuildErrors: false,
|
ignoreBuildErrors: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Enable React Compiler for automatic memoization
|
|
||||||
|
|
||||||
// Compress responses with gzip/brotli
|
// Compress responses with gzip/brotli
|
||||||
compress: true,
|
compress: true,
|
||||||
|
|
||||||
@@ -65,6 +63,11 @@ const nextConfig: NextConfig = {
|
|||||||
hideLogsAfterAbort: true, // <-- Dit verbergt de prerender bail-out logberichten!
|
hideLogsAfterAbort: true, // <-- Dit verbergt de prerender bail-out logberichten!
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Optimize images served through next/image with sharp → AVIF/WebP.
|
||||||
|
images: {
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
},
|
||||||
|
|
||||||
// Add caching headers for static assets
|
// Add caching headers for static assets
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+8
-8
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@hookform/resolvers": "5.7.1",
|
"@hookform/resolvers": "5.9.0",
|
||||||
"@next/bundle-analyzer": "16.3.0",
|
"@next/bundle-analyzer": "16.3.1",
|
||||||
"@tanstack/react-virtual": "3.14.9",
|
"@tanstack/react-virtual": "3.14.9",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"motion": "13.1.0",
|
"motion": "13.1.0",
|
||||||
"music-metadata": "11.14.0",
|
"music-metadata": "11.14.0",
|
||||||
"mysql2": "3.23.3",
|
"mysql2": "3.23.3",
|
||||||
"next": "16.3.0",
|
"next": "16.3.1",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.13.6",
|
"next-intl": "4.13.6",
|
||||||
"otplib": "13.4.1",
|
"otplib": "13.4.1",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "7.85.0",
|
"react-hook-form": "7.85.0",
|
||||||
"resend": "6.19.0",
|
"resend": "6.20.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "0.35.3",
|
"sharp": "0.35.3",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.2.0",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.2.4",
|
||||||
"@vitest/coverage-v8": "4.1.10",
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
@@ -79,9 +79,9 @@
|
|||||||
"knip": "6.32.2",
|
"knip": "6.32.2",
|
||||||
"lint-staged": "^17.3.0",
|
"lint-staged": "^17.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.25",
|
"postcss": "^8.5.26",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.5",
|
"tsx": "^4.23.12",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "8.2.1",
|
"vite": "8.2.1",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
|
|||||||
Generated
+73
-72
@@ -35,11 +35,11 @@ importers:
|
|||||||
specifier: 3.2.2
|
specifier: 3.2.2
|
||||||
version: 3.2.2([email protected])
|
version: 3.2.2([email protected])
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: 5.7.1
|
specifier: 5.9.0
|
||||||
version: 5.7.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
version: 5.9.0(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||||
'@next/bundle-analyzer':
|
'@next/bundle-analyzer':
|
||||||
specifier: 16.3.0
|
specifier: 16.3.1
|
||||||
version: 16.3.0
|
version: 16.3.1
|
||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: 3.14.9
|
specifier: 3.14.9
|
||||||
version: 3.14.9([email protected]([email protected]))([email protected])
|
version: 3.14.9([email protected]([email protected]))([email protected])
|
||||||
@@ -95,14 +95,14 @@ importers:
|
|||||||
specifier: 3.23.3
|
specifier: 3.23.3
|
||||||
version: 3.23.3(@types/[email protected])
|
version: 3.23.3(@types/[email protected])
|
||||||
next:
|
next:
|
||||||
specifier: 16.3.0
|
specifier: 16.3.1
|
||||||
version: 16.3.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 16.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
next-auth:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.32
|
specifier: 5.0.0-beta.32
|
||||||
version: 5.0.0-beta.32([email protected].0(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
version: 5.0.0-beta.32([email protected].1(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||||
next-intl:
|
next-intl:
|
||||||
specifier: 4.13.6
|
specifier: 4.13.6
|
||||||
version: 4.13.6([email protected].0(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
version: 4.13.6(@swc/[email protected])([email protected].1(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||||
otplib:
|
otplib:
|
||||||
specifier: 13.4.1
|
specifier: 13.4.1
|
||||||
version: 13.4.1
|
version: 13.4.1
|
||||||
@@ -119,8 +119,8 @@ importers:
|
|||||||
specifier: 7.85.0
|
specifier: 7.85.0
|
||||||
version: 7.85.0([email protected])
|
version: 7.85.0([email protected])
|
||||||
resend:
|
resend:
|
||||||
specifier: 6.19.0
|
specifier: 6.20.0
|
||||||
version: 6.19.0
|
version: 6.20.0
|
||||||
server-only:
|
server-only:
|
||||||
specifier: 0.0.1
|
specifier: 0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
@@ -150,7 +150,7 @@ importers:
|
|||||||
specifier: ^0.5.20
|
specifier: ^0.5.20
|
||||||
version: 0.5.20([email protected])
|
version: 0.5.20([email protected])
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.1.2
|
specifier: ^26.2.0
|
||||||
version: 26.2.0
|
version: 26.2.0
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: 19.2.18
|
specifier: 19.2.18
|
||||||
@@ -183,7 +183,7 @@ importers:
|
|||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.23.5
|
specifier: ^4.23.12
|
||||||
version: 4.23.12
|
version: 4.23.12
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^7.0.2
|
specifier: ^7.0.2
|
||||||
@@ -559,8 +559,8 @@ packages:
|
|||||||
'@formatjs/[email protected]':
|
'@formatjs/[email protected]':
|
||||||
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
||||||
|
|
||||||
'@hookform/resolvers@5.7.1':
|
'@hookform/resolvers@5.9.0':
|
||||||
resolution: {integrity: sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==}
|
resolution: {integrity: sha512-8sNo1IklGaONrsKzXjwZJNsPbccAXLXGX//G+VFEcQOviMtbvR8/fM6HWyA0qrjgpOYauwoodda53CQIWOZ7Ag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@sinclair/typebox': '>=0.25.24'
|
'@sinclair/typebox': '>=0.25.24'
|
||||||
'@standard-schema/spec': ^1.0.0
|
'@standard-schema/spec': ^1.0.0
|
||||||
@@ -578,13 +578,13 @@ packages:
|
|||||||
fluentvalidation-ts: ^3.0.0
|
fluentvalidation-ts: ^3.0.0
|
||||||
fp-ts: ^2.7.0
|
fp-ts: ^2.7.0
|
||||||
io-ts: ^2.0.0
|
io-ts: ^2.0.0
|
||||||
joi: ^17.0.0
|
joi: ^17.0.0 || ^18.0.0
|
||||||
nope-validator: '>=0.12.0'
|
nope-validator: '>=0.12.0'
|
||||||
react-hook-form: ^7.55.0
|
react-hook-form: ^7.55.0
|
||||||
superstruct: '>=0.12.0'
|
superstruct: '>=0.12.0'
|
||||||
typanion: ^3.3.2
|
typanion: ^3.3.2
|
||||||
valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
|
valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
|
||||||
vest: '>=3.0.0'
|
vest: '>=6.0.0'
|
||||||
yup: ^1.0.0
|
yup: ^1.0.0
|
||||||
zod: ^3.25.0 || ^4.0.0
|
zod: ^3.25.0 || ^4.0.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
@@ -825,60 +825,60 @@ packages:
|
|||||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-o8OiXKIATcSC6kHm5BIEmaDEDTFUDhpy9POQIbzAQTlb8NWwFhEQheTLFSbyQXxH2ywhjZ/e8EUlbOjj2k22Yg==}
|
resolution: {integrity: sha512-/6XQeYPHM6jF1gTeJ82Mu6yXOHQIFVxzAn3DkPtHDp5v6SDXoCicBMTHloN+2h4WKFCQujSLCgLZHItJ3jN1uw==}
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
|
resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==}
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
|
resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
|
resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
|
resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
|
resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
|
resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
|
resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
|
resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
|
resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -1584,8 +1584,8 @@ packages:
|
|||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
'@swc/[email protected].15':
|
'@swc/[email protected].23':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==}
|
resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==}
|
||||||
@@ -2583,8 +2583,8 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
[email protected].0:
|
[email protected].1:
|
||||||
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2758,8 +2758,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||||
|
|
||||||
resend@6.19.0:
|
resend@6.20.0:
|
||||||
resolution: {integrity: sha512-JnEdYnd9WyBDIzunsEZtUF8n3cBKM9SlmySaos/7wwi4sEXKG1Zz4M64VFAyk+278erX01Fq2wHQ3p7OIdPriA==}
|
resolution: {integrity: sha512-fXDFt7jVMuka6ruS79DzaQFKPyxOAUtoYUGSl3dTUyOnFK9klmUULxADQRzFFtHfHRipYyy62jYcm4cMKqvtjw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@react-email/render': '*'
|
'@react-email/render': '*'
|
||||||
@@ -3372,7 +3372,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/fast-memoize': 3.1.7
|
'@formatjs/fast-memoize': 3.1.7
|
||||||
|
|
||||||
'@hookform/resolvers@5.7.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])':
|
'@hookform/resolvers@5.9.0(@standard-schema/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@standard-schema/utils': 0.3.0
|
||||||
react-hook-form: 7.85.0([email protected])
|
react-hook-form: 7.85.0([email protected])
|
||||||
@@ -3514,37 +3514,37 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.3
|
'@tybys/wasm-util': 0.10.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
dependencies:
|
dependencies:
|
||||||
webpack-bundle-analyzer: 4.10.1
|
webpack-bundle-analyzer: 4.10.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
'@next/[email protected].0': {}
|
'@next/[email protected].1': {}
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].0':
|
'@next/[email protected].1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@noble/[email protected]': {}
|
'@noble/[email protected]': {}
|
||||||
@@ -3985,7 +3985,7 @@ snapshots:
|
|||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected](@swc/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
'@swc/types': 0.1.28
|
'@swc/types': 0.1.28
|
||||||
@@ -4002,10 +4002,11 @@ snapshots:
|
|||||||
'@swc/core-win32-arm64-msvc': 1.15.47
|
'@swc/core-win32-arm64-msvc': 1.15.47
|
||||||
'@swc/core-win32-ia32-msvc': 1.15.47
|
'@swc/core-win32-ia32-msvc': 1.15.47
|
||||||
'@swc/core-win32-x64-msvc': 1.15.47
|
'@swc/core-win32-x64-msvc': 1.15.47
|
||||||
|
'@swc/helpers': 0.5.23
|
||||||
|
|
||||||
'@swc/[email protected]': {}
|
'@swc/[email protected]': {}
|
||||||
|
|
||||||
'@swc/[email protected].15':
|
'@swc/[email protected].23':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
@@ -4753,22 +4754,22 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected].0(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
[email protected]([email protected].1(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@auth/core': 0.41.3
|
'@auth/core': 0.41.3
|
||||||
next: 16.3.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
next: 16.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected].0(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
[email protected](@swc/[email protected])([email protected].1(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@formatjs/intl-localematcher': 0.8.13
|
'@formatjs/intl-localematcher': 0.8.13
|
||||||
'@parcel/watcher': 2.6.0
|
'@parcel/watcher': 2.6.0
|
||||||
'@swc/core': 1.15.47
|
'@swc/core': 1.15.47(@swc/[email protected])
|
||||||
icu-minify: 4.13.6
|
icu-minify: 4.13.6
|
||||||
negotiator: 1.0.0
|
negotiator: 1.0.0
|
||||||
next: 16.3.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
next: 16.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
next-intl-swc-plugin-extractor: 4.13.6
|
next-intl-swc-plugin-extractor: 4.13.6
|
||||||
po-parser: 2.1.1
|
po-parser: 2.1.1
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
@@ -4778,10 +4779,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@swc/helpers'
|
- '@swc/helpers'
|
||||||
|
|
||||||
[email protected].0(@types/[email protected])([email protected]([email protected]))([email protected]):
|
[email protected].1(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.3.0
|
'@next/env': 16.3.1
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.23
|
||||||
baseline-browser-mapping: 2.11.13
|
baseline-browser-mapping: 2.11.13
|
||||||
caniuse-lite: 1.0.30001809
|
caniuse-lite: 1.0.30001809
|
||||||
postcss: 8.5.26
|
postcss: 8.5.26
|
||||||
@@ -4789,14 +4790,14 @@ snapshots:
|
|||||||
react-dom: 19.2.8([email protected])
|
react-dom: 19.2.8([email protected])
|
||||||
styled-jsx: 5.1.6([email protected])
|
styled-jsx: 5.1.6([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.3.0
|
'@next/swc-darwin-arm64': 16.3.1
|
||||||
'@next/swc-darwin-x64': 16.3.0
|
'@next/swc-darwin-x64': 16.3.1
|
||||||
'@next/swc-linux-arm64-gnu': 16.3.0
|
'@next/swc-linux-arm64-gnu': 16.3.1
|
||||||
'@next/swc-linux-arm64-musl': 16.3.0
|
'@next/swc-linux-arm64-musl': 16.3.1
|
||||||
'@next/swc-linux-x64-gnu': 16.3.0
|
'@next/swc-linux-x64-gnu': 16.3.1
|
||||||
'@next/swc-linux-x64-musl': 16.3.0
|
'@next/swc-linux-x64-musl': 16.3.1
|
||||||
'@next/swc-win32-arm64-msvc': 16.3.0
|
'@next/swc-win32-arm64-msvc': 16.3.1
|
||||||
'@next/swc-win32-x64-msvc': 16.3.0
|
'@next/swc-win32-x64-msvc': 16.3.1
|
||||||
sharp: 0.35.3(@types/[email protected])
|
sharp: 0.35.3(@types/[email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -5004,7 +5005,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
resend@6.19.0:
|
resend@6.20.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
postal-mime: 2.7.5
|
postal-mime: 2.7.5
|
||||||
standardwebhooks: 1.0.0
|
standardwebhooks: 1.0.0
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { redirect } from "next/navigation";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteAds } from "@/lib/db";
|
import { db, WebsiteAds } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
@@ -117,28 +116,3 @@ export const deleteAd = adminAction(
|
|||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
|
|
||||||
export async function deleteAdForm(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
|
||||||
const id = formPositiveBigInt(formData, "id");
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "ad_delete",
|
|
||||||
description: `Deleted advertisement #${id}`,
|
|
||||||
targetType: "website_ad",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("Action failed: deleteAdForm", {
|
|
||||||
action: "deleteAdForm",
|
|
||||||
id: Number(id),
|
|
||||||
error: err instanceof Error ? err.message : "DB error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
redirect("/admin/ads");
|
|
||||||
}
|
|
||||||
@@ -6,31 +6,14 @@ import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { precheckLogin } from "./auth-precheck";
|
import { precheckLogin } from "./auth-precheck";
|
||||||
|
|
||||||
const { selectLimit } = vi.hoisted(() => {
|
const { queryPreparedOne } = vi.hoisted(() => {
|
||||||
const selectLimit = vi.fn().mockResolvedValue([]);
|
const queryPreparedOne = vi.fn().mockResolvedValue(null);
|
||||||
return { selectLimit };
|
return { queryPreparedOne };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } }));
|
vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } }));
|
||||||
vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() }));
|
vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() }));
|
||||||
vi.mock("@/lib/db", () => ({
|
vi.mock("@/lib/db", () => ({ queryPreparedOne }));
|
||||||
db: {
|
|
||||||
select: vi.fn(() => ({
|
|
||||||
from: vi.fn(() => ({
|
|
||||||
where: vi.fn(() => ({
|
|
||||||
limit: selectLimit,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
User: {
|
|
||||||
password: "password",
|
|
||||||
twoFactorConfirmedAt: "twoFactorConfirmedAt",
|
|
||||||
mail: "mail",
|
|
||||||
mailVerified: "mailVerified",
|
|
||||||
username: "username",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||||
vi.mock("@/lib/services/captcha", () => ({
|
vi.mock("@/lib/services/captcha", () => ({
|
||||||
captchaConfig: vi.fn(),
|
captchaConfig: vi.fn(),
|
||||||
@@ -46,31 +29,27 @@ beforeEach(() => {
|
|||||||
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||||
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
|
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
|
||||||
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
|
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
|
||||||
selectLimit.mockResolvedValue([]);
|
queryPreparedOne.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("precheckLogin", () => {
|
describe("precheckLogin", () => {
|
||||||
it("returns ok for valid login without 2FA", async () => {
|
it("returns ok for valid login without 2FA", async () => {
|
||||||
selectLimit.mockResolvedValue([
|
queryPreparedOne.mockResolvedValue({
|
||||||
{
|
password: "hash",
|
||||||
password: "hash",
|
twoFactorConfirmedAt: null,
|
||||||
twoFactorConfirmedAt: null,
|
mail: null,
|
||||||
mail: null,
|
mailVerified: "0",
|
||||||
mailVerified: "0",
|
});
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(await precheckLogin("user", "pass")).toBe("ok");
|
expect(await precheckLogin("user", "pass")).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns twofactor when 2FA is set up", async () => {
|
it("returns twofactor when 2FA is set up", async () => {
|
||||||
selectLimit.mockResolvedValue([
|
queryPreparedOne.mockResolvedValue({
|
||||||
{
|
password: "hash",
|
||||||
password: "hash",
|
twoFactorConfirmedAt: new Date(),
|
||||||
twoFactorConfirmedAt: new Date(),
|
mail: null,
|
||||||
mail: null,
|
mailVerified: "0",
|
||||||
mailVerified: "0",
|
});
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,33 +62,29 @@ describe("precheckLogin", () => {
|
|||||||
provider: "hcaptcha",
|
provider: "hcaptcha",
|
||||||
} as never);
|
} as never);
|
||||||
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
||||||
selectLimit.mockResolvedValue([
|
queryPreparedOne.mockResolvedValue({
|
||||||
{
|
password: "hash",
|
||||||
password: "hash",
|
twoFactorConfirmedAt: null,
|
||||||
twoFactorConfirmedAt: null,
|
mail: null,
|
||||||
mail: null,
|
mailVerified: "0",
|
||||||
mailVerified: "0",
|
});
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns invalid when user not found (dummy hash check)", async () => {
|
it("returns invalid when user not found (dummy hash check)", async () => {
|
||||||
selectLimit.mockResolvedValue([]);
|
queryPreparedOne.mockResolvedValue(null);
|
||||||
const result = await precheckLogin("nonexistent", "pass");
|
const result = await precheckLogin("nonexistent", "pass");
|
||||||
expect(result).toBe("invalid");
|
expect(result).toBe("invalid");
|
||||||
expect(checkLogin).toHaveBeenCalled();
|
expect(checkLogin).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns unverified when email verification required", async () => {
|
it("returns unverified when email verification required", async () => {
|
||||||
selectLimit.mockResolvedValue([
|
queryPreparedOne.mockResolvedValue({
|
||||||
{
|
password: "hash",
|
||||||
password: "hash",
|
twoFactorConfirmedAt: null,
|
||||||
twoFactorConfirmedAt: null,
|
mail: "[email protected]",
|
||||||
mail: "[email protected]",
|
mailVerified: "0",
|
||||||
mailVerified: "0",
|
});
|
||||||
},
|
|
||||||
]);
|
|
||||||
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
|
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
|
||||||
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { checkLogin } from "@/lib/auth/password";
|
import { checkLogin } from "@/lib/auth/password";
|
||||||
import { db, User } from "@/lib/db";
|
import { queryPreparedOne } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -46,17 +45,17 @@ export async function precheckLogin(
|
|||||||
mailVerified: string;
|
mailVerified: string;
|
||||||
} | null;
|
} | null;
|
||||||
try {
|
try {
|
||||||
const [row] = await db
|
user = await queryPreparedOne<{
|
||||||
.select({
|
password: string;
|
||||||
password: User.password,
|
twoFactorConfirmedAt: Date | null;
|
||||||
twoFactorConfirmedAt: User.twoFactorConfirmedAt,
|
mail: string | null;
|
||||||
mail: User.mail,
|
mailVerified: string;
|
||||||
mailVerified: User.mailVerified,
|
}>(
|
||||||
})
|
`SELECT password, two_factor_confirmed_at AS twoFactorConfirmedAt,
|
||||||
.from(User)
|
mail, mail_verified AS mailVerified
|
||||||
.where(eq(User.username, u))
|
FROM users WHERE username = ? LIMIT 1`,
|
||||||
.limit(1);
|
[u],
|
||||||
user = row ?? null;
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return "invalid";
|
return "invalid";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
|||||||
if (!error) {
|
if (!error) {
|
||||||
try {
|
try {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select({
|
||||||
|
token: PasswordReset.token,
|
||||||
|
createdAt: PasswordReset.createdAt,
|
||||||
|
})
|
||||||
.from(PasswordReset)
|
.from(PasswordReset)
|
||||||
.where(eq(PasswordReset.email, email))
|
.where(eq(PasswordReset.email, email))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
+12
-2
@@ -166,7 +166,12 @@ export const voteOnPoll = authAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [poll] = await db
|
const [poll] = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: WebsitePoll.id,
|
||||||
|
status: WebsitePoll.status,
|
||||||
|
startsAt: WebsitePoll.startsAt,
|
||||||
|
endsAt: WebsitePoll.endsAt,
|
||||||
|
})
|
||||||
.from(WebsitePoll)
|
.from(WebsitePoll)
|
||||||
.where(eq(WebsitePoll.id, ctx.data.pollId))
|
.where(eq(WebsitePoll.id, ctx.data.pollId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -184,7 +189,12 @@ export const voteOnPoll = authAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const questions = await db
|
const questions = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: WebsitePollQuestion.id,
|
||||||
|
pollId: WebsitePollQuestion.pollId,
|
||||||
|
type: WebsitePollQuestion.type,
|
||||||
|
options: WebsitePollQuestion.options,
|
||||||
|
})
|
||||||
.from(WebsitePollQuestion)
|
.from(WebsitePollQuestion)
|
||||||
.where(eq(WebsitePollQuestion.pollId, poll.id));
|
.where(eq(WebsitePollQuestion.pollId, poll.id));
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export async function register(
|
|||||||
String(formData.get("look") ?? "")
|
String(formData.get("look") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim() || DEFAULT_LOOK,
|
.trim() || DEFAULT_LOOK,
|
||||||
|
termsAccepted: formData.get("terms") === "on",
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = registerSchema.safeParse(raw);
|
const parsed = registerSchema.safeParse(raw);
|
||||||
@@ -76,6 +77,10 @@ export async function register(
|
|||||||
return "Captcha verification failed. Please try again.";
|
return "Captcha verification failed. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terms acceptance check.
|
||||||
|
if (!raw.termsAccepted)
|
||||||
|
return "You must accept the terms and conditions to register.";
|
||||||
|
|
||||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||||
if ((await checkVpn(ip)).blocked) {
|
if ((await checkVpn(ip)).blocked) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+17
-4
@@ -60,7 +60,10 @@ export const adminReplyTicket = adminAction(
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
const [ticket] = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: WebsiteTicket.id,
|
||||||
|
assigneeId: WebsiteTicket.assigneeId,
|
||||||
|
})
|
||||||
.from(WebsiteTicket)
|
.from(WebsiteTicket)
|
||||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -106,7 +109,11 @@ export const updateTicketStatus = adminAction(
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
const [ticket] = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: WebsiteTicket.id,
|
||||||
|
assigneeId: WebsiteTicket.assigneeId,
|
||||||
|
status: WebsiteTicket.status,
|
||||||
|
})
|
||||||
.from(WebsiteTicket)
|
.from(WebsiteTicket)
|
||||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -149,7 +156,10 @@ export const assignTicket = adminAction(
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
const [ticket] = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: WebsiteTicket.id,
|
||||||
|
assigneeId: WebsiteTicket.assigneeId,
|
||||||
|
})
|
||||||
.from(WebsiteTicket)
|
.from(WebsiteTicket)
|
||||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -185,7 +195,10 @@ export const updateTicketPriority = adminAction(
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
const [ticket] = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: WebsiteTicket.id,
|
||||||
|
priority: WebsiteTicket.priority,
|
||||||
|
})
|
||||||
.from(WebsiteTicket)
|
.from(WebsiteTicket)
|
||||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type CurrencyKind,
|
type CurrencyKind,
|
||||||
} from "@/components/shared/currency-icon";
|
} from "@/components/shared/currency-icon";
|
||||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata = { title: "Leaderboard" };
|
export const metadata = { title: "Leaderboard" };
|
||||||
@@ -43,15 +44,17 @@ type Row = { username: string; look: string; value: number };
|
|||||||
|
|
||||||
async function loadCreditsRows(): Promise<Row[]> {
|
async function loadCreditsRows(): Promise<Row[]> {
|
||||||
try {
|
try {
|
||||||
const users = await db
|
const users = await cached("lb_credits", 60_000, () =>
|
||||||
.select({
|
db
|
||||||
username: User.username,
|
.select({
|
||||||
look: User.look,
|
username: User.username,
|
||||||
credits: User.credits,
|
look: User.look,
|
||||||
})
|
credits: User.credits,
|
||||||
.from(User)
|
})
|
||||||
.orderBy(desc(User.credits))
|
.from(User)
|
||||||
.limit(20);
|
.orderBy(desc(User.credits))
|
||||||
|
.limit(20),
|
||||||
|
);
|
||||||
return users.map((u) => ({
|
return users.map((u) => ({
|
||||||
username: u.username,
|
username: u.username,
|
||||||
look: u.look,
|
look: u.look,
|
||||||
@@ -64,35 +67,19 @@ async function loadCreditsRows(): Promise<Row[]> {
|
|||||||
|
|
||||||
async function loadCurrencyRows(type: number): Promise<Row[]> {
|
async function loadCurrencyRows(type: number): Promise<Row[]> {
|
||||||
try {
|
try {
|
||||||
const top = await db
|
return await cached(`lb_currency_${type}`, 60_000, () =>
|
||||||
.select({
|
db
|
||||||
userId: UsersCurrency.userId,
|
.select({
|
||||||
amount: UsersCurrency.amount,
|
username: User.username,
|
||||||
})
|
look: User.look,
|
||||||
.from(UsersCurrency)
|
value: UsersCurrency.amount,
|
||||||
.where(eq(UsersCurrency.type, type))
|
})
|
||||||
.orderBy(desc(UsersCurrency.amount))
|
.from(UsersCurrency)
|
||||||
.limit(20);
|
.innerJoin(User, eq(UsersCurrency.userId, User.id))
|
||||||
if (top.length === 0) return [];
|
.where(eq(UsersCurrency.type, type))
|
||||||
|
.orderBy(desc(UsersCurrency.amount))
|
||||||
const users = await db
|
.limit(20),
|
||||||
.select({ id: User.id, username: User.username, look: User.look })
|
);
|
||||||
.from(User)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
User.id,
|
|
||||||
top.map((t) => t.userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const byId = new Map(users.map((u) => [u.id, u]));
|
|
||||||
|
|
||||||
return top
|
|
||||||
.map((t) => {
|
|
||||||
const u = byId.get(t.userId);
|
|
||||||
if (!u) return null;
|
|
||||||
return { username: u.username, look: u.look, value: t.amount };
|
|
||||||
})
|
|
||||||
.filter((r): r is Row => r !== null);
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -103,38 +90,18 @@ async function loadSettingsRows(
|
|||||||
): Promise<Row[]> {
|
): Promise<Row[]> {
|
||||||
try {
|
try {
|
||||||
const column = UsersSettings[field];
|
const column = UsersSettings[field];
|
||||||
const top = await db
|
return await cached(`lb_settings_${field}`, 60_000, () =>
|
||||||
.select({
|
db
|
||||||
userId: UsersSettings.userId,
|
.select({
|
||||||
value: column,
|
username: User.username,
|
||||||
})
|
look: User.look,
|
||||||
.from(UsersSettings)
|
value: column,
|
||||||
.orderBy(desc(column))
|
})
|
||||||
.limit(20);
|
.from(UsersSettings)
|
||||||
if (top.length === 0) return [];
|
.innerJoin(User, eq(UsersSettings.userId, User.id))
|
||||||
|
.orderBy(desc(column))
|
||||||
const users = await db
|
.limit(20),
|
||||||
.select({ id: User.id, username: User.username, look: User.look })
|
);
|
||||||
.from(User)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
User.id,
|
|
||||||
top.map((t) => t.userId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const byId = new Map(users.map((u) => [u.id, u]));
|
|
||||||
|
|
||||||
return top
|
|
||||||
.map((t) => {
|
|
||||||
const u = byId.get(t.userId);
|
|
||||||
if (!u) return null;
|
|
||||||
return {
|
|
||||||
username: u.username,
|
|
||||||
look: u.look,
|
|
||||||
value: Number(t.value ?? 0),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((r): r is Row => r !== null);
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-33
@@ -18,7 +18,6 @@ import {
|
|||||||
UsersBadges,
|
UsersBadges,
|
||||||
UsersSettings,
|
UsersSettings,
|
||||||
WebsiteLoginLogs,
|
WebsiteLoginLogs,
|
||||||
WebsiteSetting,
|
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
@@ -85,9 +84,9 @@ export default async function MePage({
|
|||||||
const [
|
const [
|
||||||
userRows,
|
userRows,
|
||||||
hotelName,
|
hotelName,
|
||||||
neededRows,
|
neededRaw,
|
||||||
amountRows,
|
rewardAmountRaw,
|
||||||
currencyRows,
|
currencySettings,
|
||||||
alertRaw,
|
alertRaw,
|
||||||
recentRooms,
|
recentRooms,
|
||||||
badges,
|
badges,
|
||||||
@@ -112,29 +111,12 @@ export default async function MePage({
|
|||||||
.where(eq(User.id, userId))
|
.where(eq(User.id, userId))
|
||||||
.limit(1),
|
.limit(1),
|
||||||
resolveHotelName(),
|
resolveHotelName(),
|
||||||
db
|
siteSettings.get("referrals_needed", "5"),
|
||||||
.select({ value: WebsiteSetting.value })
|
siteSettings.get("referral_reward_amount", "30"),
|
||||||
.from(WebsiteSetting)
|
siteSettings.getMany([
|
||||||
.where(eq(WebsiteSetting.key, "referrals_needed"))
|
"referral_reward_currency_type",
|
||||||
.limit(1)
|
"referral_reward_currency",
|
||||||
.catch(() => []),
|
]),
|
||||||
db
|
|
||||||
.select({ value: WebsiteSetting.value })
|
|
||||||
.from(WebsiteSetting)
|
|
||||||
.where(eq(WebsiteSetting.key, "referral_reward_amount"))
|
|
||||||
.limit(1)
|
|
||||||
.catch(() => []),
|
|
||||||
db
|
|
||||||
.select({ value: WebsiteSetting.value })
|
|
||||||
.from(WebsiteSetting)
|
|
||||||
.where(
|
|
||||||
inArray(WebsiteSetting.key, [
|
|
||||||
"referral_reward_currency_type",
|
|
||||||
"referral_reward_currency",
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
.catch(() => []),
|
|
||||||
siteSettings.get("hotel_alert_banner", ""),
|
siteSettings.get("hotel_alert_banner", ""),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -191,9 +173,6 @@ export default async function MePage({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const user = userRows[0] ?? null;
|
const user = userRows[0] ?? null;
|
||||||
const neededRaw = neededRows[0] ?? null;
|
|
||||||
const amountRaw = amountRows[0] ?? null;
|
|
||||||
const currencyRaw = currencyRows[0] ?? null;
|
|
||||||
const lastWebLogin = lastWebLoginRows[0] ?? null;
|
const lastWebLogin = lastWebLoginRows[0] ?? null;
|
||||||
const userSettings = userSettingsRows[0] ?? null;
|
const userSettings = userSettingsRows[0] ?? null;
|
||||||
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
||||||
@@ -203,9 +182,13 @@ export default async function MePage({
|
|||||||
|
|
||||||
if (!user) throw new Error("user-not-found");
|
if (!user) throw new Error("user-not-found");
|
||||||
|
|
||||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
const needed = Number.parseInt(neededRaw ?? "5", 10) || 5;
|
||||||
const rewardAmount = Number.parseInt(amountRaw?.value ?? "30", 10) || 0;
|
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
||||||
const rewardCurrency = (currencyRaw?.value ?? "diamonds")
|
const rewardCurrency = (
|
||||||
|
currencySettings.referral_reward_currency_type ??
|
||||||
|
currencySettings.referral_reward_currency ??
|
||||||
|
"diamonds"
|
||||||
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export default async function ArticlePage({
|
|||||||
<img
|
<img
|
||||||
src={article.image}
|
src={article.image}
|
||||||
alt=""
|
alt=""
|
||||||
|
decoding="async"
|
||||||
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { desc } from "drizzle-orm";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import { getNewsList } from "@/lib/services/news-list";
|
||||||
|
|
||||||
export const metadata = { title: "News" };
|
export const metadata = { title: "News" };
|
||||||
|
|
||||||
export default async function NewsPage() {
|
export default async function NewsPage() {
|
||||||
const t = await getTranslations("pages.news");
|
const t = await getTranslations("pages.news");
|
||||||
|
|
||||||
let articles: (typeof WebsiteArticles.$inferSelect)[] = [];
|
const articles = await getNewsList(30);
|
||||||
try {
|
|
||||||
articles = await db
|
|
||||||
.select()
|
|
||||||
.from(WebsiteArticles)
|
|
||||||
.orderBy(desc(WebsiteArticles.createdAt))
|
|
||||||
.limit(30);
|
|
||||||
} catch {
|
|
||||||
// DB unavailable — render the empty state rather than a 500.
|
|
||||||
articles = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
@@ -58,8 +47,8 @@ export default async function NewsPage() {
|
|||||||
src={a.image}
|
src={a.image}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
sizes="(max-width: 1024px) 50vw, 300px"
|
||||||
style={{ objectFit: "cover" }}
|
style={{ objectFit: "cover" }}
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+35
-30
@@ -15,10 +15,11 @@ import { ThemeSwitcher } from "@/components/theme-switcher";
|
|||||||
import { TypewriterText } from "@/components/typewriter-text";
|
import { TypewriterText } from "@/components/typewriter-text";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
|
import { getNewsList } from "@/lib/services/news-list";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
async function getHotelData() {
|
async function getHotelData() {
|
||||||
@@ -43,42 +44,40 @@ async function getHotelData() {
|
|||||||
.where(eq(User.online, "1"))
|
.where(eq(User.online, "1"))
|
||||||
.then((rows) => rows[0]?.total ?? 0),
|
.then((rows) => rows[0]?.total ?? 0),
|
||||||
).catch(() => 0),
|
).catch(() => 0),
|
||||||
cached("total_users", 30_000, () =>
|
cached("total_users", 300_000, () =>
|
||||||
db
|
db
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(User)
|
.from(User)
|
||||||
.then((rows) => rows[0]?.total ?? 0),
|
.then((rows) => rows[0]?.total ?? 0),
|
||||||
).catch(() => 0),
|
).catch(() => 0),
|
||||||
cached("total_rooms", 30_000, () =>
|
cached("total_rooms", 300_000, () =>
|
||||||
db
|
db
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(Rooms)
|
.from(Rooms)
|
||||||
.then((rows) => rows[0]?.total ?? 0),
|
.then((rows) => rows[0]?.total ?? 0),
|
||||||
).catch(() => 0),
|
).catch(() => 0),
|
||||||
db
|
getNewsList(4),
|
||||||
.select()
|
cached("home_online_users", 15_000, () =>
|
||||||
.from(WebsiteArticles)
|
db
|
||||||
.orderBy(desc(WebsiteArticles.createdAt))
|
.select({ username: User.username, look: User.look })
|
||||||
.limit(5)
|
.from(User)
|
||||||
.catch(() => []),
|
.where(eq(User.online, "1"))
|
||||||
db
|
.limit(30),
|
||||||
.select({ username: User.username, look: User.look })
|
).catch(() => []),
|
||||||
.from(User)
|
cached("home_recent_photos", 60_000, () =>
|
||||||
.where(eq(User.online, "1"))
|
db
|
||||||
.limit(30)
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||||
.catch(() => []),
|
.from(CameraWeb)
|
||||||
db
|
.orderBy(desc(CameraWeb.timestamp))
|
||||||
.select()
|
.limit(4),
|
||||||
.from(CameraWeb)
|
).catch(() => []),
|
||||||
.orderBy(desc(CameraWeb.timestamp))
|
cached("home_latest_users", 60_000, () =>
|
||||||
.limit(4)
|
db
|
||||||
.catch(() => []),
|
.select({ username: User.username, look: User.look })
|
||||||
db
|
.from(User)
|
||||||
.select({ username: User.username, look: User.look })
|
.orderBy(desc(User.accountCreated))
|
||||||
.from(User)
|
.limit(8),
|
||||||
.orderBy(desc(User.accountCreated))
|
).catch(() => []),
|
||||||
.limit(8)
|
|
||||||
.catch(() => []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -191,12 +190,18 @@ export default async function Home() {
|
|||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-2xl border"
|
className="relative overflow-hidden rounded-2xl border"
|
||||||
style={{
|
style={{
|
||||||
background: `url(/assets/images/next_header.jpg) center/cover no-repeat`,
|
|
||||||
backgroundPosition: "center 20%",
|
|
||||||
borderColor:
|
borderColor:
|
||||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/next_header.jpg"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="100vw"
|
||||||
|
style={{ objectFit: "cover", objectPosition: "center 20%" }}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
@@ -556,8 +561,8 @@ export default async function Home() {
|
|||||||
src={a.image}
|
src={a.image}
|
||||||
alt={a.title}
|
alt={a.title}
|
||||||
fill
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 400px"
|
||||||
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2.5 right-2.5 w-8 h-8 rounded-full flex items-center justify-center bg-black/50 backdrop-blur-sm transition-all duration-300 opacity-0 group-hover:opacity-100 translate-x-1 group-hover:translate-x-0">
|
<div className="absolute top-2.5 right-2.5 w-8 h-8 rounded-full flex items-center justify-center bg-black/50 backdrop-blur-sm transition-all duration-300 opacity-0 group-hover:opacity-100 translate-x-1 group-hover:translate-x-0">
|
||||||
<span className="text-white text-sm font-bold">
|
<span className="text-white text-sm font-bold">
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ interface AuditEntry {
|
|||||||
action: string;
|
action: string;
|
||||||
target: string;
|
target: string;
|
||||||
targetId: number | null;
|
targetId: number | null;
|
||||||
before: unknown;
|
|
||||||
after: unknown;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ interface AuditEvent {
|
|||||||
sqlCount?: number;
|
sqlCount?: number;
|
||||||
applied?: number;
|
applied?: number;
|
||||||
sqlFailed?: number;
|
sqlFailed?: number;
|
||||||
furniData?: { added: number; removedDuplicates: number };
|
furniData?: {
|
||||||
|
added: number;
|
||||||
|
removedDuplicates: number;
|
||||||
|
removedIdConflicts: number;
|
||||||
|
};
|
||||||
structure?: {
|
structure?: {
|
||||||
orphanedRemoved: number;
|
orphanedRemoved: number;
|
||||||
orphanedCleaned: number;
|
orphanedCleaned: number;
|
||||||
@@ -155,6 +159,7 @@ export function AuditClient() {
|
|||||||
const [applySql, setApplySql] = useState(false);
|
const [applySql, setApplySql] = useState(false);
|
||||||
const [repairFurniData, setRepairFurniData] = useState(false);
|
const [repairFurniData, setRepairFurniData] = useState(false);
|
||||||
const [repairStructure, setRepairStructure] = useState(false);
|
const [repairStructure, setRepairStructure] = useState(false);
|
||||||
|
const [checkFurniDataIds, setCheckFurniDataIds] = useState(false);
|
||||||
|
|
||||||
const [issues, setIssues] = useState<AuditIssue[]>([]);
|
const [issues, setIssues] = useState<AuditIssue[]>([]);
|
||||||
const [missingFromSources, setMissingFromSources] = useState<
|
const [missingFromSources, setMissingFromSources] = useState<
|
||||||
@@ -174,6 +179,7 @@ export function AuditClient() {
|
|||||||
const [furniDataResult, setFurniDataResult] = useState<{
|
const [furniDataResult, setFurniDataResult] = useState<{
|
||||||
added: number;
|
added: number;
|
||||||
removedDuplicates: number;
|
removedDuplicates: number;
|
||||||
|
removedIdConflicts: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [structureResult, setStructureResult] = useState<{
|
const [structureResult, setStructureResult] = useState<{
|
||||||
orphanedRemoved: number;
|
orphanedRemoved: number;
|
||||||
@@ -222,6 +228,7 @@ export function AuditClient() {
|
|||||||
applySql,
|
applySql,
|
||||||
repairFurniData,
|
repairFurniData,
|
||||||
repairStructure,
|
repairStructure,
|
||||||
|
checkFurniDataIds,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -358,6 +365,7 @@ export function AuditClient() {
|
|||||||
applySql,
|
applySql,
|
||||||
repairFurniData,
|
repairFurniData,
|
||||||
repairStructure,
|
repairStructure,
|
||||||
|
checkFurniDataIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const errors = issues.filter((i) => i.severity === "error");
|
const errors = issues.filter((i) => i.severity === "error");
|
||||||
@@ -421,6 +429,13 @@ export function AuditClient() {
|
|||||||
>
|
>
|
||||||
Repair FurnitureData.json (add missing + dedupe)
|
Repair FurnitureData.json (add missing + dedupe)
|
||||||
</AuditCheckbox>
|
</AuditCheckbox>
|
||||||
|
<AuditCheckbox
|
||||||
|
checked={checkFurniDataIds}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={setCheckFurniDataIds}
|
||||||
|
>
|
||||||
|
Check FurnitureData.json for spriteId conflicts
|
||||||
|
</AuditCheckbox>
|
||||||
<AuditCheckbox
|
<AuditCheckbox
|
||||||
checked={repairStructure}
|
checked={repairStructure}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -665,6 +680,15 @@ export function AuditClient() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{furniDataResult.removedIdConflicts > 0 && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<span className="text-[var(--admin-warning)] font-medium">
|
||||||
|
{furniDataResult.removedIdConflicts} spriteId conflicts
|
||||||
|
fixed
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,18 @@ export async function loadRoom(id: string) {
|
|||||||
if (Number.isNaN(roomId)) notFound();
|
if (Number.isNaN(roomId)) notFound();
|
||||||
|
|
||||||
const [room] = await db
|
const [room] = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: Rooms.id,
|
||||||
|
ownerId: Rooms.ownerId,
|
||||||
|
ownerName: Rooms.ownerName,
|
||||||
|
name: Rooms.name,
|
||||||
|
description: Rooms.description,
|
||||||
|
state: Rooms.state,
|
||||||
|
users: Rooms.users,
|
||||||
|
usersMax: Rooms.usersMax,
|
||||||
|
category: Rooms.category,
|
||||||
|
score: Rooms.score,
|
||||||
|
})
|
||||||
.from(Rooms)
|
.from(Rooms)
|
||||||
.where(eq(Rooms.id, roomId))
|
.where(eq(Rooms.id, roomId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "captcha_provider",
|
key: "captcha_provider",
|
||||||
label: "Captcha provider",
|
label: "Captcha provider",
|
||||||
description: "none | turnstile | recaptcha",
|
description: "none | turnstile | recaptcha | hcaptcha",
|
||||||
type: "text",
|
type: "text",
|
||||||
defaultValue: "none",
|
defaultValue: "none",
|
||||||
},
|
},
|
||||||
@@ -278,6 +278,11 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
|||||||
label: "Turnstile site key",
|
label: "Turnstile site key",
|
||||||
type: "text",
|
type: "text",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "hcaptcha_site_key",
|
||||||
|
label: "hCaptcha site key",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "recaptcha_site_key",
|
key: "recaptcha_site_key",
|
||||||
label: "reCAPTCHA site key",
|
label: "reCAPTCHA site key",
|
||||||
|
|||||||
@@ -21,7 +21,24 @@ export async function loadUserById(id: string) {
|
|||||||
numericId > 0 &&
|
numericId > 0 &&
|
||||||
String(numericId) === id;
|
String(numericId) === id;
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: User.id,
|
||||||
|
username: User.username,
|
||||||
|
mail: User.mail,
|
||||||
|
gender: User.gender,
|
||||||
|
machineId: User.machineId,
|
||||||
|
twoFactorConfirmedAt: User.twoFactorConfirmedAt,
|
||||||
|
rank: User.rank,
|
||||||
|
online: User.online,
|
||||||
|
motto: User.motto,
|
||||||
|
look: User.look,
|
||||||
|
credits: User.credits,
|
||||||
|
pixels: User.pixels,
|
||||||
|
accountCreated: User.accountCreated,
|
||||||
|
lastLogin: User.lastLogin,
|
||||||
|
ipRegister: User.ipRegister,
|
||||||
|
ipCurrent: User.ipCurrent,
|
||||||
|
})
|
||||||
.from(User)
|
.from(User)
|
||||||
.where(isNumeric ? eq(User.id, numericId) : eq(User.username, id))
|
.where(isNumeric ? eq(User.id, numericId) : eq(User.username, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -40,7 +57,10 @@ export async function loadUserById(id: string) {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.select().from(UsersCurrency).where(eq(UsersCurrency.userId, user.id)),
|
db.select().from(UsersCurrency).where(eq(UsersCurrency.userId, user.id)),
|
||||||
db
|
db
|
||||||
.select()
|
.select({
|
||||||
|
id: UsersBadges.id,
|
||||||
|
badgeCode: UsersBadges.badgeCode,
|
||||||
|
})
|
||||||
.from(UsersBadges)
|
.from(UsersBadges)
|
||||||
.where(eq(UsersBadges.userId, user.id))
|
.where(eq(UsersBadges.userId, user.id))
|
||||||
.orderBy(asc(UsersBadges.slotId)),
|
.orderBy(asc(UsersBadges.slotId)),
|
||||||
|
|||||||
@@ -27,7 +27,20 @@ type PageRow = {
|
|||||||
|
|
||||||
async function getBcTreeFlat(): Promise<TreeNode[]> {
|
async function getBcTreeFlat(): Promise<TreeNode[]> {
|
||||||
const [allPages, itemCounts] = await Promise.all([
|
const [allPages, itemCounts] = await Promise.all([
|
||||||
db.select().from(CatalogPagesBc).orderBy(asc(CatalogPagesBc.orderNum)),
|
db
|
||||||
|
.select({
|
||||||
|
id: CatalogPagesBc.id,
|
||||||
|
parentId: CatalogPagesBc.parentId,
|
||||||
|
caption: CatalogPagesBc.caption,
|
||||||
|
pageLayout: CatalogPagesBc.pageLayout,
|
||||||
|
iconColor: CatalogPagesBc.iconColor,
|
||||||
|
iconImage: CatalogPagesBc.iconImage,
|
||||||
|
orderNum: CatalogPagesBc.orderNum,
|
||||||
|
visible: CatalogPagesBc.visible,
|
||||||
|
enabled: CatalogPagesBc.enabled,
|
||||||
|
})
|
||||||
|
.from(CatalogPagesBc)
|
||||||
|
.orderBy(asc(CatalogPagesBc.orderNum)),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
pageId: CatalogItemsBc.pageId,
|
pageId: CatalogItemsBc.pageId,
|
||||||
@@ -108,7 +121,17 @@ async function getChildren(
|
|||||||
|
|
||||||
if (isBc) {
|
if (isBc) {
|
||||||
const pages = await db
|
const pages = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: CatalogPagesBc.id,
|
||||||
|
parentId: CatalogPagesBc.parentId,
|
||||||
|
caption: CatalogPagesBc.caption,
|
||||||
|
pageLayout: CatalogPagesBc.pageLayout,
|
||||||
|
iconColor: CatalogPagesBc.iconColor,
|
||||||
|
iconImage: CatalogPagesBc.iconImage,
|
||||||
|
orderNum: CatalogPagesBc.orderNum,
|
||||||
|
visible: CatalogPagesBc.visible,
|
||||||
|
enabled: CatalogPagesBc.enabled,
|
||||||
|
})
|
||||||
.from(CatalogPagesBc)
|
.from(CatalogPagesBc)
|
||||||
.where(parentFilterBc)
|
.where(parentFilterBc)
|
||||||
.orderBy(asc(CatalogPagesBc.orderNum));
|
.orderBy(asc(CatalogPagesBc.orderNum));
|
||||||
@@ -163,7 +186,17 @@ async function getChildren(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pages = await db
|
const pages = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: CatalogPages.id,
|
||||||
|
parentId: CatalogPages.parentId,
|
||||||
|
caption: CatalogPages.caption,
|
||||||
|
pageLayout: CatalogPages.pageLayout,
|
||||||
|
iconColor: CatalogPages.iconColor,
|
||||||
|
iconImage: CatalogPages.iconImage,
|
||||||
|
orderNum: CatalogPages.orderNum,
|
||||||
|
visible: CatalogPages.visible,
|
||||||
|
enabled: CatalogPages.enabled,
|
||||||
|
})
|
||||||
.from(CatalogPages)
|
.from(CatalogPages)
|
||||||
.where(parentFilter)
|
.where(parentFilter)
|
||||||
.orderBy(asc(CatalogPages.orderNum));
|
.orderBy(asc(CatalogPages.orderNum));
|
||||||
@@ -213,7 +246,17 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
|
|||||||
const conditions = [like(CatalogPagesBc.caption, `%${needle}%`)];
|
const conditions = [like(CatalogPagesBc.caption, `%${needle}%`)];
|
||||||
if (idOk) conditions.push(eq(CatalogPagesBc.id, idExact));
|
if (idOk) conditions.push(eq(CatalogPagesBc.id, idExact));
|
||||||
const pages = await db
|
const pages = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: CatalogPagesBc.id,
|
||||||
|
parentId: CatalogPagesBc.parentId,
|
||||||
|
caption: CatalogPagesBc.caption,
|
||||||
|
pageLayout: CatalogPagesBc.pageLayout,
|
||||||
|
iconColor: CatalogPagesBc.iconColor,
|
||||||
|
iconImage: CatalogPagesBc.iconImage,
|
||||||
|
orderNum: CatalogPagesBc.orderNum,
|
||||||
|
visible: CatalogPagesBc.visible,
|
||||||
|
enabled: CatalogPagesBc.enabled,
|
||||||
|
})
|
||||||
.from(CatalogPagesBc)
|
.from(CatalogPagesBc)
|
||||||
.where(or(...conditions))
|
.where(or(...conditions))
|
||||||
.orderBy(asc(CatalogPagesBc.orderNum))
|
.orderBy(asc(CatalogPagesBc.orderNum))
|
||||||
@@ -240,7 +283,17 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
|
|||||||
const conditions = [like(CatalogPages.caption, `%${needle}%`)];
|
const conditions = [like(CatalogPages.caption, `%${needle}%`)];
|
||||||
if (idOk) conditions.push(eq(CatalogPages.id, idExact));
|
if (idOk) conditions.push(eq(CatalogPages.id, idExact));
|
||||||
const pages = await db
|
const pages = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: CatalogPages.id,
|
||||||
|
parentId: CatalogPages.parentId,
|
||||||
|
caption: CatalogPages.caption,
|
||||||
|
pageLayout: CatalogPages.pageLayout,
|
||||||
|
iconColor: CatalogPages.iconColor,
|
||||||
|
iconImage: CatalogPages.iconImage,
|
||||||
|
orderNum: CatalogPages.orderNum,
|
||||||
|
visible: CatalogPages.visible,
|
||||||
|
enabled: CatalogPages.enabled,
|
||||||
|
})
|
||||||
.from(CatalogPages)
|
.from(CatalogPages)
|
||||||
.where(or(...conditions))
|
.where(or(...conditions))
|
||||||
.orderBy(asc(CatalogPages.orderNum))
|
.orderBy(asc(CatalogPages.orderNum))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const POST = withAdmin(
|
|||||||
let applySql = false;
|
let applySql = false;
|
||||||
let repairFurniData = false;
|
let repairFurniData = false;
|
||||||
let repairStructure = false;
|
let repairStructure = false;
|
||||||
|
let checkFurniDataIds = false;
|
||||||
try {
|
try {
|
||||||
const body = (await request.json().catch(() => ({}))) as {
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
repair?: unknown;
|
repair?: unknown;
|
||||||
@@ -26,6 +27,7 @@ export const POST = withAdmin(
|
|||||||
applySql?: unknown;
|
applySql?: unknown;
|
||||||
repairFurniData?: unknown;
|
repairFurniData?: unknown;
|
||||||
repairStructure?: unknown;
|
repairStructure?: unknown;
|
||||||
|
checkFurniDataIds?: unknown;
|
||||||
};
|
};
|
||||||
repair = body.repair === true;
|
repair = body.repair === true;
|
||||||
repairNitros = body.repairNitros === true;
|
repairNitros = body.repairNitros === true;
|
||||||
@@ -33,6 +35,7 @@ export const POST = withAdmin(
|
|||||||
applySql = body.applySql === true;
|
applySql = body.applySql === true;
|
||||||
repairFurniData = body.repairFurniData === true;
|
repairFurniData = body.repairFurniData === true;
|
||||||
repairStructure = body.repairStructure === true;
|
repairStructure = body.repairStructure === true;
|
||||||
|
checkFurniDataIds = body.checkFurniDataIds === true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn("Failed to parse audit request body", { err });
|
logger.warn("Failed to parse audit request body", { err });
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,7 @@ export const POST = withAdmin(
|
|||||||
applySql,
|
applySql,
|
||||||
repairFurniData,
|
repairFurniData,
|
||||||
repairStructure,
|
repairStructure,
|
||||||
|
checkFurniDataIds,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
send({
|
send({
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export const POST = withAdmin(
|
|||||||
type: item.type ?? "flooritem",
|
type: item.type ?? "flooritem",
|
||||||
revision: item.revision ?? 0,
|
revision: item.revision ?? 0,
|
||||||
category: item.category ?? "unknown",
|
category: item.category ?? "unknown",
|
||||||
|
updateExisting: body.updateExisting === true,
|
||||||
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
||||||
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
||||||
iconBaseUrl: source?.iconBaseUrl || undefined,
|
iconBaseUrl: source?.iconBaseUrl || undefined,
|
||||||
@@ -359,6 +360,7 @@ export const POST = withAdmin(
|
|||||||
revision,
|
revision,
|
||||||
category,
|
category,
|
||||||
sourceId,
|
sourceId,
|
||||||
|
updateExisting,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!classname || !name) return apiError("Missing classname or name", 400);
|
if (!classname || !name) return apiError("Missing classname or name", 400);
|
||||||
@@ -374,6 +376,7 @@ export const POST = withAdmin(
|
|||||||
type: type ?? "flooritem",
|
type: type ?? "flooritem",
|
||||||
revision: revision ?? 0,
|
revision: revision ?? 0,
|
||||||
category: category ?? "unknown",
|
category: category ?? "unknown",
|
||||||
|
updateExisting: updateExisting === true,
|
||||||
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
||||||
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
||||||
iconBaseUrl: source?.iconBaseUrl || undefined,
|
iconBaseUrl: source?.iconBaseUrl || undefined,
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ const MAX_MESSAGE_LENGTH = 255;
|
|||||||
export async function GET(_req: Request) {
|
export async function GET(_req: Request) {
|
||||||
try {
|
try {
|
||||||
const shouts = await db
|
const shouts = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: RadioShouts.id,
|
||||||
|
userId: RadioShouts.userId,
|
||||||
|
message: RadioShouts.message,
|
||||||
|
createdAt: RadioShouts.createdAt,
|
||||||
|
})
|
||||||
.from(RadioShouts)
|
.from(RadioShouts)
|
||||||
.orderBy(desc(RadioShouts.createdAt))
|
.orderBy(desc(RadioShouts.createdAt))
|
||||||
.limit(50);
|
.limit(50);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import sharp from "sharp";
|
||||||
import { resolveImagerBase } from "@/lib/imager";
|
import { resolveImagerBase } from "@/lib/imager";
|
||||||
|
|
||||||
const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i;
|
const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i;
|
||||||
@@ -6,6 +7,19 @@ const FIGURE_MAX_LEN = 512;
|
|||||||
const FIGURE_MAX_PARTS = 24;
|
const FIGURE_MAX_PARTS = 24;
|
||||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
const UPSTREAM_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
type AvatarFormat = "png" | "webp" | "avif";
|
||||||
|
|
||||||
|
function resolveFormat(raw: string | null): AvatarFormat {
|
||||||
|
switch (raw?.toLowerCase()) {
|
||||||
|
case "webp":
|
||||||
|
return "webp";
|
||||||
|
case "avif":
|
||||||
|
return "avif";
|
||||||
|
default:
|
||||||
|
return "png";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
@@ -66,6 +80,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const imgFormat = searchParams.get("img_format");
|
const imgFormat = searchParams.get("img_format");
|
||||||
if (imgFormat) params.set("img_format", imgFormat);
|
if (imgFormat) params.set("img_format", imgFormat);
|
||||||
|
|
||||||
|
const format = resolveFormat(searchParams.get("format"));
|
||||||
|
|
||||||
const upstream = resolveImagerBase();
|
const upstream = resolveImagerBase();
|
||||||
const upstreamUrl = `${upstream}?${params.toString()}`;
|
const upstreamUrl = `${upstream}?${params.toString()}`;
|
||||||
|
|
||||||
@@ -85,15 +101,35 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await res.arrayBuffer();
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
const contentType = res.headers.get("content-type") || "image/png";
|
|
||||||
|
|
||||||
return new NextResponse(buffer, {
|
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
|
||||||
|
// Falls back to the original PNG if conversion fails.
|
||||||
|
let body: Uint8Array = new Uint8Array(buffer);
|
||||||
|
let contentType = res.headers.get("content-type") || "image/png";
|
||||||
|
if (format !== "png") {
|
||||||
|
try {
|
||||||
|
if (format === "webp") {
|
||||||
|
body = new Uint8Array(await sharp(buffer).webp().toBuffer());
|
||||||
|
contentType = "image/webp";
|
||||||
|
} else {
|
||||||
|
body = new Uint8Array(await sharp(buffer).avif().toBuffer());
|
||||||
|
contentType = "image/avif";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
body = new Uint8Array(buffer);
|
||||||
|
contentType = res.headers.get("content-type") || "image/png";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(body as unknown as BodyInit, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
|
"Content-Length": String(body.length),
|
||||||
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
||||||
// stale-while-revalidate keeps them fresh without blocking requests.
|
// stale-while-revalidate keeps them fresh without blocking requests.
|
||||||
|
// The ?format= param is part of the URL, so variants cache separately.
|
||||||
"Cache-Control":
|
"Cache-Control":
|
||||||
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export function RegisterForm({
|
|||||||
|
|
||||||
{/* Terms */}
|
{/* Terms */}
|
||||||
<div
|
<div
|
||||||
className="rounded-xl p-4 flex flex-col gap-3"
|
className="rounded-xl p-4 flex gap-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
||||||
@@ -290,7 +290,7 @@ export function RegisterForm({
|
|||||||
"1px solid color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
"1px solid color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="terms"
|
id="terms"
|
||||||
@@ -301,8 +301,7 @@ export function RegisterForm({
|
|||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="terms"
|
htmlFor="terms"
|
||||||
className="font-semibold cursor-pointer select-none"
|
className="font-medium text-sm text-[var(--color-text-readable)]"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
|
||||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -314,6 +313,9 @@ export function RegisterForm({
|
|||||||
{t("termsAccept", { hotel: hotelName })}
|
{t("termsAccept", { hotel: hotelName })}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
* Required for account creation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Captcha */}
|
{/* Captcha */}
|
||||||
@@ -323,6 +325,9 @@ export function RegisterForm({
|
|||||||
{showCaptcha && captcha.provider === "recaptcha" && (
|
{showCaptcha && captcha.provider === "recaptcha" && (
|
||||||
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
||||||
)}
|
)}
|
||||||
|
{showCaptcha && captcha.provider === "hcaptcha" && (
|
||||||
|
<div className="h-captcha" data-sitekey={captcha.siteKey} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export const User = mysqlTable("users", {
|
|||||||
homeRoom: int("home_room").notNull().default(0),
|
homeRoom: int("home_room").notNull().default(0),
|
||||||
secretKey: varchar("secret_key", { length: 40 }),
|
secretKey: varchar("secret_key", { length: 40 }),
|
||||||
pincode: varchar("pincode", { length: 11 }),
|
pincode: varchar("pincode", { length: 11 }),
|
||||||
|
termsAccepted: boolean("terms_accepted").notNull().default(false),
|
||||||
|
ageVerified: boolean("age_verified").notNull().default(false),
|
||||||
extraRank: int("extra_rank"),
|
extraRank: int("extra_rank"),
|
||||||
twoFactorSecret: text("two_factor_secret"),
|
twoFactorSecret: text("two_factor_secret"),
|
||||||
twoFactorRecoveryCodes: text("two_factor_recovery_codes"),
|
twoFactorRecoveryCodes: text("two_factor_recovery_codes"),
|
||||||
@@ -389,6 +391,20 @@ export const LogsShopPurchases = mysqlTable("logs_shop_purchases", {
|
|||||||
amount: int("amount"),
|
amount: int("amount"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const LogsFurniImports = mysqlTable("logs_furni_imports", {
|
||||||
|
id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(),
|
||||||
|
timestamp: int("timestamp", { unsigned: true }).notNull(),
|
||||||
|
userId: int("user_id").notNull(),
|
||||||
|
batch: tinyint("batch").notNull().default(0),
|
||||||
|
totalItems: int("total_items").notNull().default(0),
|
||||||
|
succeeded: int("succeeded").notNull().default(0),
|
||||||
|
failed: int("failed").notNull().default(0),
|
||||||
|
details: text("details").notNull(),
|
||||||
|
rolledBack: tinyint("rolled_back").notNull().default(0),
|
||||||
|
rolledBackAt: int("rolled_back_at", { unsigned: true }),
|
||||||
|
rolledBackBy: int("rolled_back_by"),
|
||||||
|
});
|
||||||
|
|
||||||
export const MarketplaceItems = mysqlTable("marketplace_items", {
|
export const MarketplaceItems = mysqlTable("marketplace_items", {
|
||||||
id: int("id").autoincrement().primaryKey().notNull(),
|
id: int("id").autoincrement().primaryKey().notNull(),
|
||||||
itemId: int("item_id").notNull(),
|
itemId: int("item_id").notNull(),
|
||||||
|
|||||||
+18
-8
@@ -5,6 +5,11 @@ import { redis } from "@/lib/redis";
|
|||||||
type CacheEntry<T> = { data: T; expiresAt: number };
|
type CacheEntry<T> = { data: T; expiresAt: number };
|
||||||
const memory = new Map<string, CacheEntry<unknown>>();
|
const memory = new Map<string, CacheEntry<unknown>>();
|
||||||
|
|
||||||
|
/** Drop a key from the in-process cache (used when an upstream value changes). */
|
||||||
|
export function invalidateMemory(key: string): void {
|
||||||
|
memory.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis-first cached query with an in-memory fallback.
|
* Redis-first cached query with an in-memory fallback.
|
||||||
* Use for read-heavy endpoints polled by the browser (online count, etc.).
|
* Use for read-heavy endpoints polled by the browser (online count, etc.).
|
||||||
@@ -16,24 +21,29 @@ export async function cached<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const ttlSec = Math.ceil(ttlMs / 1000);
|
const ttlSec = Math.ceil(ttlMs / 1000);
|
||||||
|
|
||||||
|
// In-memory fast path — served first so repeated reads within a TTL window
|
||||||
|
// don't each pay a Redis round-trip (Redis is still the shared fallback).
|
||||||
|
// `existing &&` short-circuits so Date.now() is never evaluated during
|
||||||
|
// prerender when the map is empty (keeps `next build` prerendering clean).
|
||||||
|
const existing = memory.get(key);
|
||||||
|
if (existing && existing.expiresAt > Date.now()) {
|
||||||
|
return existing.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
// Redis path (shared across instances).
|
// Redis path (shared across instances).
|
||||||
if (redis && redis.status !== "end") {
|
if (redis && redis.status !== "end") {
|
||||||
try {
|
try {
|
||||||
const cached = await redis.get(key);
|
const cached = await redis.get(key);
|
||||||
if (cached !== null && cached !== undefined) {
|
if (cached !== null && cached !== undefined) {
|
||||||
return JSON.parse(cached) as T;
|
const data = JSON.parse(cached) as T;
|
||||||
|
memory.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through to DB / memory */
|
/* fall through to fn */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory fallback (single-instance fast path).
|
|
||||||
const existing = memory.get(key);
|
|
||||||
if (existing && existing.expiresAt > Date.now()) {
|
|
||||||
return existing.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fn();
|
const data = await fn();
|
||||||
|
|
||||||
if (redis && redis.status !== "end") {
|
if (redis && redis.status !== "end") {
|
||||||
|
|||||||
+17
-37
@@ -1,52 +1,32 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { logger } from "@/lib/logger";
|
import { cached, invalidateMemory } from "@/lib/cache";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
const DEFAULT_CACHE_TTL = 60;
|
const DEFAULT_CACHE_TTL = 60;
|
||||||
|
|
||||||
function isRedisAvailable(): boolean {
|
/**
|
||||||
return !!redis && redis.status !== "end";
|
* Redis-backed cached query with an in-process fast path (see `cached()`).
|
||||||
}
|
* Use for read-heavy lookups that are safe to serve slightly stale
|
||||||
|
* (login user, article bodies, …). Redis stays the shared source of truth.
|
||||||
|
*/
|
||||||
export async function cachedQuery<T>(
|
export async function cachedQuery<T>(
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
queryFn: () => Promise<T>,
|
queryFn: () => Promise<T>,
|
||||||
ttl: number = DEFAULT_CACHE_TTL,
|
ttl: number = DEFAULT_CACHE_TTL,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!isRedisAvailable()) {
|
return cached(cacheKey, ttl * 1000, queryFn);
|
||||||
return queryFn();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cached = await redis?.get(cacheKey);
|
|
||||||
if (cached !== null && cached !== undefined) {
|
|
||||||
return JSON.parse(cached) as T;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("[cache] read failed, falling back to DB", {
|
|
||||||
key: cacheKey,
|
|
||||||
error: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await queryFn();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await redis?.setex(cacheKey, ttl, JSON.stringify(result));
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("[cache] write failed, continuing without cache", {
|
|
||||||
key: cacheKey,
|
|
||||||
error: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invalidate a single cache key immediately. */
|
/** Invalidate a single cache key immediately (memory + Redis). */
|
||||||
export async function invalidateKey(key: string): Promise<number> {
|
export async function invalidateKey(key: string): Promise<number> {
|
||||||
if (!isRedisAvailable()) return 0;
|
invalidateMemory(key);
|
||||||
if (!redis) return 0;
|
if (redis && redis.status !== "end") {
|
||||||
return redis.del(key);
|
try {
|
||||||
|
return await redis.del(key);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
+36
-9
@@ -1,4 +1,5 @@
|
|||||||
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import * as relations from "@/db/relations";
|
import * as relations from "@/db/relations";
|
||||||
import * as schema from "@/db/schema";
|
import * as schema from "@/db/schema";
|
||||||
@@ -8,10 +9,10 @@ import { resolveConnectionLimit } from "@/lib/db-pool";
|
|||||||
const fullSchema = { ...schema, ...relations };
|
const fullSchema = { ...schema, ...relations };
|
||||||
|
|
||||||
const globalForDb = globalThis as unknown as {
|
const globalForDb = globalThis as unknown as {
|
||||||
db?: MySql2Database<typeof fullSchema>;
|
db?: ReturnType<typeof createDb>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDb(): MySql2Database<typeof fullSchema> {
|
function createDb() {
|
||||||
const url = new URL(env.DATABASE_URL);
|
const url = new URL(env.DATABASE_URL);
|
||||||
const isBuild =
|
const isBuild =
|
||||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||||
@@ -35,11 +36,6 @@ function createDb(): MySql2Database<typeof fullSchema> {
|
|||||||
connectTimeout,
|
connectTimeout,
|
||||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||||
enableKeepAlive: true,
|
enableKeepAlive: true,
|
||||||
// Prepared-statement caching via mysql2's native support (Node 22+).
|
|
||||||
// Saves query-parse per request on hot paths.
|
|
||||||
...("cache" in mysql.createPool && {
|
|
||||||
cache: { type: "prepared" },
|
|
||||||
}),
|
|
||||||
// Allow :placeholder syntax for raw SQL helpers.
|
// Allow :placeholder syntax for raw SQL helpers.
|
||||||
namedPlaceholders: true,
|
namedPlaceholders: true,
|
||||||
// Reject multiple statements (SQL injection hardening).
|
// Reject multiple statements (SQL injection hardening).
|
||||||
@@ -65,6 +61,37 @@ export const db = globalForDb.db ?? createDb();
|
|||||||
|
|
||||||
if (env.NODE_ENV !== "production") globalForDb.db = db;
|
if (env.NODE_ENV !== "production") globalForDb.db = db;
|
||||||
|
|
||||||
export type Db = MySql2Database<typeof fullSchema>;
|
export type Db = ReturnType<typeof createDb>;
|
||||||
|
|
||||||
|
type PreparedValue = string | number | bigint | boolean | Date | null | Buffer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side prepared statements (mysql2 pool.execute()).
|
||||||
|
*
|
||||||
|
* Drizzle's mysql2 driver runs `.select()` through the text protocol
|
||||||
|
* (pool.query()), so it never uses server-side prepared statements. For
|
||||||
|
* hot-path queries the SQL is parsed once on the server per connection
|
||||||
|
* instead of every execution. Column aliases let you pick exactly the
|
||||||
|
* columns you need (no SELECT *).
|
||||||
|
*/
|
||||||
|
export async function queryPrepared<T extends Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params?: PreparedValue[],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const client = db.$client as MySqlPool;
|
||||||
|
const [rows] = (await client.execute(sql, params)) as unknown as [
|
||||||
|
T[],
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
return rows ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryPreparedOne<T extends Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params?: PreparedValue[],
|
||||||
|
): Promise<T | null> {
|
||||||
|
const rows = await queryPrepared<T>(sql, params);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export * from "@/db/schema";
|
export * from "@/db/schema";
|
||||||
+4
-23
@@ -1,36 +1,17 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { redis } from "@/lib/redis";
|
import { cached } from "@/lib/cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache the result of a fetch function in Redis.
|
* Cache the result of a fetch function, memory-first with a Redis fallback
|
||||||
* Falls back to the fresh fetch if Redis is unavailable.
|
* (see `cached()`). TTL is given in seconds.
|
||||||
*/
|
*/
|
||||||
export async function redisCache<T>(
|
export async function redisCache<T>(
|
||||||
key: string,
|
key: string,
|
||||||
ttlSeconds: number,
|
ttlSeconds: number,
|
||||||
fetch: () => Promise<T>,
|
fetch: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!redis) return fetch();
|
return cached(key, ttlSeconds * 1000, fetch);
|
||||||
|
|
||||||
try {
|
|
||||||
const cached = await redis.get(key);
|
|
||||||
if (cached !== null) {
|
|
||||||
return JSON.parse(cached) as T;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// cache miss or error — fall through to fresh fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
const fresh = await fetch();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await redis.setex(key, ttlSeconds, JSON.stringify(fresh));
|
|
||||||
} catch {
|
|
||||||
// ignore write errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return fresh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -87,7 +87,15 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
|||||||
|
|
||||||
const [rows, totalResult] = await Promise.all([
|
const [rows, totalResult] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select({
|
||||||
|
id: AdminAuditLog.id,
|
||||||
|
userId: AdminAuditLog.userId,
|
||||||
|
action: AdminAuditLog.action,
|
||||||
|
target: AdminAuditLog.target,
|
||||||
|
targetId: AdminAuditLog.targetId,
|
||||||
|
diff: AdminAuditLog.diff,
|
||||||
|
createdAt: AdminAuditLog.createdAt,
|
||||||
|
})
|
||||||
.from(AdminAuditLog)
|
.from(AdminAuditLog)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(desc(AdminAuditLog.id))
|
.orderBy(desc(AdminAuditLog.id))
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
||||||
* provider in housekeeping. Supports Cloudflare Turnstile and Google reCAPTCHA
|
* provider in housekeeping. Supports Cloudflare Turnstile, Google reCAPTCHA,
|
||||||
* (the two AtomCMS offers, mutually exclusive).
|
* and hCaptcha (three AtomCMS offers, mutually exclusive).
|
||||||
*
|
*
|
||||||
* Behaviour:
|
* Behaviour:
|
||||||
* - provider "none" (or unset) → fail-open (allow)
|
* - provider "none" (or unset) → fail-open (allow)
|
||||||
@@ -11,12 +11,13 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
* API error, or network failure → fail-closed (deny)
|
* API error, or network failure → fail-closed (deny)
|
||||||
*
|
*
|
||||||
* Settings keys:
|
* Settings keys:
|
||||||
* captcha_provider = "turnstile" | "recaptcha" | "none" (default none)
|
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none" (default none)
|
||||||
* turnstile_secret / turnstile_site_key
|
* turnstile_secret / turnstile_site_key
|
||||||
* recaptcha_secret / recaptcha_site_key
|
* recaptcha_secret / recaptcha_site_key
|
||||||
|
* hcaptcha_secret / hcaptcha_site_key
|
||||||
*/
|
*/
|
||||||
export interface CaptchaConfig {
|
export interface CaptchaConfig {
|
||||||
provider: "turnstile" | "recaptcha" | "none";
|
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
|
||||||
siteKey: string;
|
siteKey: string;
|
||||||
/** Form field the widget writes the token into. */
|
/** Form field the widget writes the token into. */
|
||||||
field: string;
|
field: string;
|
||||||
@@ -25,6 +26,7 @@ export interface CaptchaConfig {
|
|||||||
const TURNSTILE_URL =
|
const TURNSTILE_URL =
|
||||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||||
|
const HCAPTCHA_URL = "https://hcaptcha.com/siteverify";
|
||||||
|
|
||||||
/** Public config the register/login pages need to render the widget (no secrets). */
|
/** Public config the register/login pages need to render the widget (no secrets). */
|
||||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||||
@@ -45,6 +47,13 @@ export async function captchaConfig(): Promise<CaptchaConfig> {
|
|||||||
field: "g-recaptcha-response",
|
field: "g-recaptcha-response",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (provider === "hcaptcha") {
|
||||||
|
return {
|
||||||
|
provider: "hcaptcha",
|
||||||
|
siteKey: (await siteSettings.get("hcaptcha_site_key", "")) ?? "",
|
||||||
|
field: "hcaptcha-response",
|
||||||
|
};
|
||||||
|
}
|
||||||
return { provider: "none", siteKey: "", field: "" };
|
return { provider: "none", siteKey: "", field: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +67,22 @@ export async function verifyCaptcha(
|
|||||||
|
|
||||||
if (!cfg.siteKey) return false;
|
if (!cfg.siteKey) return false;
|
||||||
|
|
||||||
const secretKey =
|
const secretKey: string =
|
||||||
cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
|
cfg.provider === "turnstile"
|
||||||
|
? "turnstile_secret"
|
||||||
|
: cfg.provider === "recaptcha"
|
||||||
|
? "recaptcha_secret"
|
||||||
|
: "hcaptcha_secret";
|
||||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||||
if (!secret) return false;
|
if (!secret) return false;
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|
||||||
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
|
const url: string =
|
||||||
|
cfg.provider === "turnstile"
|
||||||
|
? TURNSTILE_URL
|
||||||
|
: cfg.provider === "recaptcha"
|
||||||
|
? RECAPTCHA_URL
|
||||||
|
: HCAPTCHA_URL;
|
||||||
const body = new URLSearchParams({ secret, response: token });
|
const body = new URLSearchParams({ secret, response: token });
|
||||||
if (remoteIp) body.set("remoteip", remoteIp);
|
if (remoteIp) body.set("remoteip", remoteIp);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
repairFurniData,
|
repairFurniData,
|
||||||
repairOrphanedCatalog,
|
repairOrphanedCatalog,
|
||||||
} from "./catalog-repair";
|
} from "./catalog-repair";
|
||||||
|
import { readFurniData } from "./furni-data";
|
||||||
import { ensureDirectories } from "./furni-import";
|
import { ensureDirectories } from "./furni-import";
|
||||||
import {
|
import {
|
||||||
assetNameCandidates,
|
assetNameCandidates,
|
||||||
@@ -55,7 +56,11 @@ export interface AuditEvent {
|
|||||||
sqlCount?: number;
|
sqlCount?: number;
|
||||||
applied?: number;
|
applied?: number;
|
||||||
sqlFailed?: number;
|
sqlFailed?: number;
|
||||||
furniData?: { added: number; removedDuplicates: number };
|
furniData?: {
|
||||||
|
added: number;
|
||||||
|
removedDuplicates: number;
|
||||||
|
removedIdConflicts: number;
|
||||||
|
};
|
||||||
structure?: {
|
structure?: {
|
||||||
orphanedRemoved: number;
|
orphanedRemoved: number;
|
||||||
orphanedCleaned: number;
|
orphanedCleaned: number;
|
||||||
@@ -118,6 +123,7 @@ export interface AuditSummary {
|
|||||||
duplicatesMerged: number;
|
duplicatesMerged: number;
|
||||||
duplicateRowsRemoved: number;
|
duplicateRowsRemoved: number;
|
||||||
remappedReferences: number;
|
remappedReferences: number;
|
||||||
|
furniDataIdConflicts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogAuditOptions {
|
export interface CatalogAuditOptions {
|
||||||
@@ -128,6 +134,7 @@ export interface CatalogAuditOptions {
|
|||||||
organizeSql?: boolean;
|
organizeSql?: boolean;
|
||||||
repairFurniData?: boolean;
|
repairFurniData?: boolean;
|
||||||
repairStructure?: boolean;
|
repairStructure?: boolean;
|
||||||
|
checkFurniDataIds?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCatalogAudit(
|
export async function runCatalogAudit(
|
||||||
@@ -712,6 +719,7 @@ export async function runCatalogAudit(
|
|||||||
furniData: {
|
furniData: {
|
||||||
added: result.added,
|
added: result.added,
|
||||||
removedDuplicates: result.removedDuplicates,
|
removedDuplicates: result.removedDuplicates,
|
||||||
|
removedIdConflicts: result.removedIdConflicts,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -722,6 +730,60 @@ export async function runCatalogAudit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── FurnitureData.json id conflict check ──────────────
|
||||||
|
let furniDataIdConflicts: {
|
||||||
|
classname: string;
|
||||||
|
itemId: number;
|
||||||
|
conflictingId: number;
|
||||||
|
existingClassname: string;
|
||||||
|
}[] = [];
|
||||||
|
if (options?.checkFurniDataIds) {
|
||||||
|
onEvent?.({
|
||||||
|
type: "progress",
|
||||||
|
message: "Checking FurnitureData.json for spriteId conflicts…",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const furniData = (await readFurniData()) as Record<
|
||||||
|
string,
|
||||||
|
{ furnitype: Array<Record<string, unknown>> }
|
||||||
|
>;
|
||||||
|
// Build map of id → classname from both sections
|
||||||
|
const idToClassname = new Map<number, string>();
|
||||||
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||||
|
for (const e of furniData[section]?.furnitype ?? []) {
|
||||||
|
const id = Number(e?.id);
|
||||||
|
const classname = typeof e?.classname === "string" ? e.classname : "";
|
||||||
|
if (!Number.isFinite(id) || id <= 0 || !classname) continue;
|
||||||
|
const existing = idToClassname.get(id);
|
||||||
|
if (existing && existing !== classname) {
|
||||||
|
// Conflict: same id used by different classname
|
||||||
|
furniDataIdConflicts.push({
|
||||||
|
classname,
|
||||||
|
itemId: id,
|
||||||
|
conflictingId: id,
|
||||||
|
existingClassname: existing,
|
||||||
|
});
|
||||||
|
} else if (!existing) {
|
||||||
|
idToClassname.set(id, classname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove duplicates (same classname + id pair counted once)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
furniDataIdConflicts = furniDataIdConflicts.filter((c) => {
|
||||||
|
const key = `${c.classname}:${c.conflictingId}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
onEvent?.({
|
||||||
|
type: "error",
|
||||||
|
message: `FurnitureData id check failed: ${(err as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
|
onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
|
||||||
|
|
||||||
const sources = await listSources();
|
const sources = await listSources();
|
||||||
@@ -811,6 +873,7 @@ export async function runCatalogAudit(
|
|||||||
duplicatesMerged,
|
duplicatesMerged,
|
||||||
duplicateRowsRemoved,
|
duplicateRowsRemoved,
|
||||||
remappedReferences,
|
remappedReferences,
|
||||||
|
furniDataIdConflicts: furniDataIdConflicts.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
onEvent?.({
|
onEvent?.({
|
||||||
|
|||||||
@@ -620,6 +620,7 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
|
|||||||
export interface FurniDataRepairResult {
|
export interface FurniDataRepairResult {
|
||||||
added: number;
|
added: number;
|
||||||
removedDuplicates: number;
|
removedDuplicates: number;
|
||||||
|
removedIdConflicts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMinimalFurniEntry(item: {
|
function buildMinimalFurniEntry(item: {
|
||||||
@@ -658,7 +659,8 @@ function buildMinimalFurniEntry(item: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add FurnitureData.json entries for items_base rows that are missing from the
|
* Add FurnitureData.json entries for items_base rows that are missing from the
|
||||||
* file, and remove duplicate classnames (keeping the first occurrence).
|
* file, remove duplicate classnames, and resolve spriteId conflicts
|
||||||
|
* (keeping the first classname per id, removing conflicting ones).
|
||||||
*/
|
*/
|
||||||
export async function repairFurniData(
|
export async function repairFurniData(
|
||||||
items: Array<{
|
items: Array<{
|
||||||
@@ -668,7 +670,8 @@ export async function repairFurniData(
|
|||||||
type: string;
|
type: string;
|
||||||
}>,
|
}>,
|
||||||
): Promise<FurniDataRepairResult> {
|
): Promise<FurniDataRepairResult> {
|
||||||
if (items.length === 0) return { added: 0, removedDuplicates: 0 };
|
if (items.length === 0)
|
||||||
|
return { added: 0, removedDuplicates: 0, removedIdConflicts: 0 };
|
||||||
|
|
||||||
return withFurniDataLock(async () => {
|
return withFurniDataLock(async () => {
|
||||||
const furniData = (await readFurniData()) as Record<
|
const furniData = (await readFurniData()) as Record<
|
||||||
@@ -697,7 +700,33 @@ export async function repairFurniData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Add entries for items_base rows missing from the file.
|
// 2) Resolve spriteId conflicts (same id used by different classnames) — keep first, remove others.
|
||||||
|
let removedIdConflicts = 0;
|
||||||
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||||
|
const sec = furniData[section];
|
||||||
|
if (!sec?.furnitype) continue;
|
||||||
|
const idToClassname = new Map<number, string>();
|
||||||
|
const deduped = sec.furnitype.filter((e) => {
|
||||||
|
const id = Number(e?.id);
|
||||||
|
const classname = typeof e.classname === "string" ? e.classname : "";
|
||||||
|
if (!Number.isFinite(id) || id <= 0 || !classname) return true;
|
||||||
|
const existing = idToClassname.get(id);
|
||||||
|
if (existing && existing !== classname) {
|
||||||
|
// Conflict: same id used by different classname — keep first, remove this
|
||||||
|
removedIdConflicts++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
idToClassname.set(id, classname);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (deduped.length !== sec.furnitype.length) {
|
||||||
|
furniData[section].furnitype = deduped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Add entries for items_base rows missing from the file.
|
||||||
const existing = new Set<string>();
|
const existing = new Set<string>();
|
||||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||||
for (const e of furniData[section]?.furnitype ?? []) {
|
for (const e of furniData[section]?.furnitype ?? []) {
|
||||||
@@ -714,8 +743,8 @@ export async function repairFurniData(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Write once if anything changed.
|
// 4) Write once if anything changed.
|
||||||
if (removedDuplicates > 0 || added.length > 0) {
|
if (removedDuplicates > 0 || removedIdConflicts > 0 || added.length > 0) {
|
||||||
for (const { entry, itemType } of added) {
|
for (const { entry, itemType } of added) {
|
||||||
const sectionKey = itemType === "i" ? "wallitemtypes" : "roomitemtypes";
|
const sectionKey = itemType === "i" ? "wallitemtypes" : "roomitemtypes";
|
||||||
if (!furniData[sectionKey]) {
|
if (!furniData[sectionKey]) {
|
||||||
@@ -725,6 +754,6 @@ export async function repairFurniData(
|
|||||||
}
|
}
|
||||||
await writeFurniData(furniData as Record<string, unknown>);
|
await writeFurniData(furniData as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
return { added: added.length, removedDuplicates };
|
return { added: added.length, removedDuplicates, removedIdConflicts };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,17 @@ export async function getCatalogItemCounts(
|
|||||||
*/
|
*/
|
||||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||||
const allPages = await db
|
const allPages = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: CatalogPages.id,
|
||||||
|
parentId: CatalogPages.parentId,
|
||||||
|
caption: CatalogPages.caption,
|
||||||
|
pageLayout: CatalogPages.pageLayout,
|
||||||
|
iconColor: CatalogPages.iconColor,
|
||||||
|
iconImage: CatalogPages.iconImage,
|
||||||
|
orderNum: CatalogPages.orderNum,
|
||||||
|
visible: CatalogPages.visible,
|
||||||
|
enabled: CatalogPages.enabled,
|
||||||
|
})
|
||||||
.from(CatalogPages)
|
.from(CatalogPages)
|
||||||
.orderBy(asc(CatalogPages.orderNum));
|
.orderBy(asc(CatalogPages.orderNum));
|
||||||
const itemCountMap = await getCatalogItemCounts();
|
const itemCountMap = await getCatalogItemCounts();
|
||||||
|
|||||||
@@ -106,6 +106,77 @@ async function acquireDiskLock(lockPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build a map of sprite id → classname from both sections of the furnidata.
|
||||||
|
* If multiple entries share the same id, the first encountered classname wins. */
|
||||||
|
function furniDataIdOwners(
|
||||||
|
data: Record<string, { furnitype: Array<Record<string, unknown>> }>,
|
||||||
|
): Map<number, string> {
|
||||||
|
const owners = new Map<number, string>();
|
||||||
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||||
|
for (const e of data[section]?.furnitype ?? []) {
|
||||||
|
const id = Number(e?.id);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) continue;
|
||||||
|
const classname = typeof e?.classname === "string" ? e.classname : "";
|
||||||
|
if (!classname) continue;
|
||||||
|
if (!owners.has(id)) owners.set(id, classname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert that no entry in `entries` has a spriteId already used by a different classname
|
||||||
|
* in the existing furnidata or within this batch. Throws if a conflict is found. */
|
||||||
|
function assertNoFurniDataIdConflicts(
|
||||||
|
existingOwners: Map<number, string>,
|
||||||
|
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
|
||||||
|
): void {
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
for (const { entry } of entries) {
|
||||||
|
const id = Number(entry?.id);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) continue;
|
||||||
|
const classname =
|
||||||
|
typeof entry?.classname === "string" ? entry.classname : "";
|
||||||
|
const existing = existingOwners.get(id);
|
||||||
|
if (existing !== undefined && existing !== classname) {
|
||||||
|
conflicts.push(
|
||||||
|
`spriteId ${id} already used by "${existing}" in FurnitureData.json ` +
|
||||||
|
`(attempted "${classname}")`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// reserve the id within this batch so a second entry with the same id + different classname
|
||||||
|
// is also caught (even though the file hasn't been written yet).
|
||||||
|
existingOwners.set(id, classname);
|
||||||
|
}
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`FurnitureData spriteId conflicts: ${conflicts.join("; ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a spriteId is already used by a different classname in the local
|
||||||
|
* FurnitureData.json. Returns the existing classname if there's a conflict,
|
||||||
|
* or null if the spriteId is free or the file couldn't be read. */
|
||||||
|
export async function findFurniDataIdConflict(
|
||||||
|
spriteId: number,
|
||||||
|
classname: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const furniData = await readFurniData();
|
||||||
|
const owners = furniDataIdOwners(
|
||||||
|
furniData as Record<
|
||||||
|
string,
|
||||||
|
{ furnitype: Array<Record<string, unknown>> }
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
const existing = owners.get(spriteId);
|
||||||
|
return existing !== undefined && existing !== classname ? existing : null;
|
||||||
|
} catch {
|
||||||
|
return null; // if file missing/unreadable, treat as no conflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
|
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
let release!: () => void;
|
let release!: () => void;
|
||||||
const acquired = new Promise<void>((r) => {
|
const acquired = new Promise<void>((r) => {
|
||||||
@@ -228,6 +299,11 @@ export async function appendFurniEntry(
|
|||||||
string,
|
string,
|
||||||
{ furnitype: Array<Record<string, unknown>> }
|
{ furnitype: Array<Record<string, unknown>> }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
// Guard: ensure the spriteId is not already used by a different classname in the file.
|
||||||
|
const existingOwners = furniDataIdOwners(furniData);
|
||||||
|
assertNoFurniDataIdConflicts(existingOwners, [{ entry, itemType }]);
|
||||||
|
|
||||||
if (itemType === "i") {
|
if (itemType === "i") {
|
||||||
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] };
|
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] };
|
||||||
upsertEntryInSection(furniData.wallitemtypes, entry);
|
upsertEntryInSection(furniData.wallitemtypes, entry);
|
||||||
@@ -331,6 +407,17 @@ export async function appendFurniEntriesBatch(
|
|||||||
string,
|
string,
|
||||||
{ furnitype: unknown[] }
|
{ furnitype: unknown[] }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
// Guard: ensure no spriteId conflicts with existing file entries
|
||||||
|
// or within this batch (same id + different classname).
|
||||||
|
const existingOwners = furniDataIdOwners(
|
||||||
|
furniData as Record<
|
||||||
|
string,
|
||||||
|
{ furnitype: Array<Record<string, unknown>> }
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
assertNoFurniDataIdConflicts(existingOwners, entries);
|
||||||
|
|
||||||
for (const { entry, itemType } of entries) {
|
for (const { entry, itemType } of entries) {
|
||||||
if (itemType === "i") {
|
if (itemType === "i") {
|
||||||
if (!furniData.wallitemtypes)
|
if (!furniData.wallitemtypes)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock("@/lib/services/furni-data", () => ({
|
|||||||
appendFurniEntry: vi.fn(),
|
appendFurniEntry: vi.fn(),
|
||||||
buildFurniEntry: vi.fn(),
|
buildFurniEntry: vi.fn(),
|
||||||
removeFurniEntry: vi.fn(),
|
removeFurniEntry: vi.fn(),
|
||||||
|
findFurniDataIdConflict: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, promises as fs } from "node:fs";
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import { CatalogPages, db, ItemsBase } from "@/lib/db";
|
import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db";
|
||||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
appendFurniEntry,
|
appendFurniEntry,
|
||||||
buildFurniEntry,
|
buildFurniEntry,
|
||||||
|
findFurniDataIdConflict,
|
||||||
removeFurniEntry,
|
removeFurniEntry,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
import {
|
import {
|
||||||
@@ -423,6 +424,7 @@ export async function importSingleFurni(params: {
|
|||||||
revision: number;
|
revision: number;
|
||||||
category: string;
|
category: string;
|
||||||
skipFurniDataWrite?: boolean;
|
skipFurniDataWrite?: boolean;
|
||||||
|
updateExisting?: boolean;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||||
sourceSwfBaseUrl?: string;
|
sourceSwfBaseUrl?: string;
|
||||||
@@ -439,6 +441,7 @@ export async function importSingleFurni(params: {
|
|||||||
revision,
|
revision,
|
||||||
category,
|
category,
|
||||||
skipFurniDataWrite,
|
skipFurniDataWrite,
|
||||||
|
updateExisting,
|
||||||
onProgress,
|
onProgress,
|
||||||
sourceSwfBaseUrl,
|
sourceSwfBaseUrl,
|
||||||
nitroBaseUrl: sourceNitroBaseUrl,
|
nitroBaseUrl: sourceNitroBaseUrl,
|
||||||
@@ -453,22 +456,38 @@ export async function importSingleFurni(params: {
|
|||||||
.where(eq(ItemsBase.itemName, classname))
|
.where(eq(ItemsBase.itemName, classname))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existsByName) {
|
if (existsByName) {
|
||||||
return {
|
if (!updateExisting) {
|
||||||
ok: false,
|
return {
|
||||||
warnings,
|
ok: false,
|
||||||
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
|
warnings,
|
||||||
};
|
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Update existing item - fall through to update logic
|
||||||
}
|
}
|
||||||
const [existsById] = await db
|
const [existsById] = await db
|
||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(eq(ItemsBase.id, originalId))
|
.where(eq(ItemsBase.id, originalId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existsById) {
|
if (existsById && (!existsByName || existsById.itemName !== classname)) {
|
||||||
|
if (!updateExisting) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
warnings,
|
||||||
|
error: `spriteId ${originalId} already used by "${existsById.itemName}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Update existing item - fall through to update logic
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check if spriteId already used in FurnitureData.json ──
|
||||||
|
const takenBy = await findFurniDataIdConflict(originalId, classname);
|
||||||
|
if (takenBy && !updateExisting) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
warnings,
|
warnings,
|
||||||
error: `spriteId ${originalId} already used by "${existsById.itemName}"`,
|
error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,6 +832,7 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// ── Create catalog entry (with duplicate check + category sub-page) ──
|
// ── Create catalog entry (with duplicate check + category sub-page) ──
|
||||||
let catalogItemId: number | null = null;
|
let catalogItemId: number | null = null;
|
||||||
|
let catalogUpdated = false;
|
||||||
try {
|
try {
|
||||||
const itemIdStr = String(spriteId);
|
const itemIdStr = String(spriteId);
|
||||||
|
|
||||||
@@ -823,9 +843,28 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
if (existingCatalog.length > 0) {
|
if (existingCatalog.length > 0) {
|
||||||
catalogItemId = existingCatalog[0].id;
|
catalogItemId = existingCatalog[0].id;
|
||||||
warnings.push(
|
if (updateExisting) {
|
||||||
`Item already in catalog (catalog_item #${catalogItemId}, page ${existingCatalog[0].page_id}) — skipped duplicate`,
|
// Update existing catalog entry: price, page, etc.
|
||||||
);
|
const categoryPageId = await getOrCreateCategoryPage(
|
||||||
|
classname,
|
||||||
|
itemType,
|
||||||
|
);
|
||||||
|
const pageIdStr = String(categoryPageId);
|
||||||
|
const price = autoPriceFurni(classname);
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE catalog_items
|
||||||
|
SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}
|
||||||
|
WHERE id = ${catalogItemId}
|
||||||
|
`);
|
||||||
|
catalogUpdated = true;
|
||||||
|
warnings.push(
|
||||||
|
`Item updated in catalog (catalog_item #${catalogItemId}, page ${categoryPageId})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warnings.push(
|
||||||
|
`Item already in catalog (catalog_item #${catalogItemId}, page ${existingCatalog[0].page_id}) — skipped duplicate`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get or create the right category sub-page based on classname
|
// Get or create the right category sub-page based on classname
|
||||||
const categoryPageId = await getOrCreateCategoryPage(classname, itemType);
|
const categoryPageId = await getOrCreateCategoryPage(classname, itemType);
|
||||||
@@ -850,6 +889,7 @@ export async function importSingleFurni(params: {
|
|||||||
ok: true,
|
ok: true,
|
||||||
itemId: spriteId,
|
itemId: spriteId,
|
||||||
catalogItemId,
|
catalogItemId,
|
||||||
|
catalogUpdated,
|
||||||
dimensions: dims,
|
dimensions: dims,
|
||||||
spriteCount: conversionResult?.spriteCount,
|
spriteCount: conversionResult?.spriteCount,
|
||||||
warnings,
|
warnings,
|
||||||
@@ -987,3 +1027,182 @@ export async function cleanupConvertedSwfs(): Promise<{
|
|||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rollback a furni import by import log ID. Returns { ok, rolledBack: number, errors: string[] }. */
|
||||||
|
export async function rollbackFurniImport(
|
||||||
|
importId: number,
|
||||||
|
rolledBackBy: number,
|
||||||
|
): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
rolledBack: number;
|
||||||
|
errors: string[];
|
||||||
|
}> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let rolledBack = 0;
|
||||||
|
|
||||||
|
// Get the import log
|
||||||
|
const [importLog] = await db
|
||||||
|
.select()
|
||||||
|
.from(LogsFurniImports)
|
||||||
|
.where(eq(LogsFurniImports.id, importId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!importLog) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
rolledBack: 0,
|
||||||
|
errors: [`Import log ${importId} not found`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importLog.rolledBack) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
rolledBack: 0,
|
||||||
|
errors: [
|
||||||
|
`Import ${importId} already rolled back at ${importLog.rolledBackAt}`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let details: {
|
||||||
|
items?: Array<{
|
||||||
|
classname: string;
|
||||||
|
spriteId: number;
|
||||||
|
baseClassname?: string;
|
||||||
|
iconFileName?: string;
|
||||||
|
}>;
|
||||||
|
} = {};
|
||||||
|
try {
|
||||||
|
details = JSON.parse(importLog.details);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
rolledBack: 0,
|
||||||
|
errors: ["Invalid import details JSON"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!details.items || !Array.isArray(details.items)) {
|
||||||
|
return { ok: false, rolledBack: 0, errors: ["No items to rollback"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetTargets = await getFurniAssetWriteTargets();
|
||||||
|
const { swfDir, iconDir, nitroDir } = assetTargets;
|
||||||
|
|
||||||
|
for (const item of details.items) {
|
||||||
|
const { classname, spriteId: _spriteId, baseClassname } = item;
|
||||||
|
const base = baseClassname || classname.split("*")[0] || classname;
|
||||||
|
|
||||||
|
const errorsForItem: string[] = [];
|
||||||
|
|
||||||
|
// 1. Delete from catalog_items
|
||||||
|
try {
|
||||||
|
await db.execute(
|
||||||
|
sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)} AND catalog_name = ${item.classname}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
errorsForItem.push(`catalog_items: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete from items_base
|
||||||
|
try {
|
||||||
|
await db.execute(
|
||||||
|
sql`DELETE FROM items_base WHERE id = ${item.spriteId} AND item_name = ${item.classname}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
errorsForItem.push(`items_base: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove from FurnitureData.json
|
||||||
|
try {
|
||||||
|
const { readFurniData, writeFurniData } = await import(
|
||||||
|
"@/lib/services/furni-data"
|
||||||
|
);
|
||||||
|
const furniData = (await readFurniData()) as {
|
||||||
|
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||||
|
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||||
|
};
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||||
|
const sec = furniData[section] as
|
||||||
|
| { furnitype: Array<{ classname?: string }> }
|
||||||
|
| undefined;
|
||||||
|
if (!sec) continue;
|
||||||
|
const furnitype = sec.furnitype ?? [];
|
||||||
|
const filtered = furnitype.filter(
|
||||||
|
(e) =>
|
||||||
|
typeof e.classname !== "string" || e.classname !== item.classname,
|
||||||
|
);
|
||||||
|
if (filtered.length !== furnitype.length) {
|
||||||
|
(
|
||||||
|
furniData[section] as { furnitype: Array<{ classname?: string }> }
|
||||||
|
).furnitype = filtered;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await writeFurniData(furniData as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorsForItem.push(`FurnitureData.json: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Delete asset files
|
||||||
|
const filesToDelete = [
|
||||||
|
path.join(nitroDir, `${base}.nitro`),
|
||||||
|
path.join(swfDir, `${base}.swf`),
|
||||||
|
path.join(iconDir, `${classname.replace(/\*/g, "_")}_icon.png`),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const filePath of filesToDelete) {
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
errorsForItem.push(
|
||||||
|
`delete ${path.basename(filePath)}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror directories
|
||||||
|
for (const dirs of assetTargets.mirrorDirs) {
|
||||||
|
for (const filePath of [
|
||||||
|
path.join(dirs.nitroDir, `${classname.split("*")[0]}.nitro`),
|
||||||
|
path.join(dirs.swfDir, `${classname.split("*")[0]}.swf`),
|
||||||
|
path.join(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`),
|
||||||
|
]) {
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorsForItem.length === 0) {
|
||||||
|
rolledBack++;
|
||||||
|
} else {
|
||||||
|
errors.push(`${item.classname}: ${errorsForItem.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as rolled back
|
||||||
|
if (rolledBack > 0) {
|
||||||
|
await db
|
||||||
|
.update(LogsFurniImports)
|
||||||
|
.set({
|
||||||
|
rolledBack: 1,
|
||||||
|
rolledBackAt: Math.floor(Date.now() / 1000),
|
||||||
|
rolledBackBy,
|
||||||
|
})
|
||||||
|
.where(eq(LogsFurniImports.id, importId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: errors.length === 0, rolledBack, errors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { desc } from "drizzle-orm";
|
||||||
|
import { cached } from "@/lib/cache";
|
||||||
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
|
|
||||||
|
export interface NewsListItem {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortStory: string;
|
||||||
|
image: string;
|
||||||
|
createdAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewsListRow {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortStory: string;
|
||||||
|
image: string;
|
||||||
|
createdAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 300_000;
|
||||||
|
const CACHE_KEY = "news_list";
|
||||||
|
const FETCH_LIMIT = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared, cached news list used by both the homepage and the news archive so
|
||||||
|
* a single "news_list" cache entry serves both routes. Returns at most
|
||||||
|
* `limit` rows with `createdAt` normalized to a Date (the cache round-trip
|
||||||
|
* serializes timestamps to ISO strings).
|
||||||
|
*/
|
||||||
|
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||||
|
try {
|
||||||
|
const rows = await cached<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
slug: WebsiteArticles.slug,
|
||||||
|
title: WebsiteArticles.title,
|
||||||
|
shortStory: WebsiteArticles.shortStory,
|
||||||
|
image: WebsiteArticles.image,
|
||||||
|
createdAt: WebsiteArticles.createdAt,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.orderBy(desc(WebsiteArticles.createdAt))
|
||||||
|
.limit(FETCH_LIMIT),
|
||||||
|
);
|
||||||
|
return rows
|
||||||
|
.map((a) => ({
|
||||||
|
...a,
|
||||||
|
createdAt: a.createdAt ? new Date(a.createdAt) : null,
|
||||||
|
}))
|
||||||
|
.slice(0, limit);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,21 @@ export interface NowPlaying {
|
|||||||
artist: string | null;
|
artist: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared in-process poll cache: every connected SSE client (players, widgets)
|
||||||
|
// would otherwise hit the radio API once per 10s each. With one cache entry per
|
||||||
|
// endpoint, N connections cost 1 request per interval regardless of N.
|
||||||
|
const POLL_CACHE_TTL_MS = 10_000;
|
||||||
|
const pollCache = new Map<string, { data: unknown; expiresAt: number }>();
|
||||||
|
|
||||||
|
async function cachedFetchJson(url: string, ms = 4000): Promise<unknown> {
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = pollCache.get(url);
|
||||||
|
if (hit && hit.expiresAt > now) return hit.data;
|
||||||
|
const data = await fetchJson(url, ms);
|
||||||
|
pollCache.set(url, { data, expiresAt: now + POLL_CACHE_TTL_MS });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson(url: string, ms = 4000): Promise<unknown> {
|
async function fetchJson(url: string, ms = 4000): Promise<unknown> {
|
||||||
if (!isSafeUrl(url)) return null;
|
if (!isSafeUrl(url)) return null;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -88,13 +103,13 @@ function parseNowPlaying(d: unknown): NowPlaying | null {
|
|||||||
export async function fetchNowPlaying(): Promise<NowPlaying | null> {
|
export async function fetchNowPlaying(): Promise<NowPlaying | null> {
|
||||||
const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? "";
|
const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? "";
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return parseNowPlaying(await fetchJson(url));
|
return parseNowPlaying(await cachedFetchJson(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchListeners(): Promise<number | null> {
|
export async function fetchListeners(): Promise<number | null> {
|
||||||
const url = (await siteSettings.get("radio_listeners_api_url", "")) ?? "";
|
const url = (await siteSettings.get("radio_listeners_api_url", "")) ?? "";
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const d = await fetchJson(url);
|
const d = await cachedFetchJson(url);
|
||||||
if (d == null) return null;
|
if (d == null) return null;
|
||||||
const obj = d as { listeners?: unknown; current_listeners?: unknown };
|
const obj = d as { listeners?: unknown; current_listeners?: unknown };
|
||||||
const raw =
|
const raw =
|
||||||
|
|||||||
@@ -15,9 +15,20 @@ const DEFAULTS: Record<string, string> = {
|
|||||||
|
|
||||||
const CACHE_TTL_MS = 300_000;
|
const CACHE_TTL_MS = 300_000;
|
||||||
const REDIS_CACHE_KEY = "site_settings";
|
const REDIS_CACHE_KEY = "site_settings";
|
||||||
|
// Short in-process window so repeated getters in one request (header, nav,
|
||||||
|
// footer all read hotel_name / logo) don't each pay a Redis round-trip.
|
||||||
|
// Redis stays the source of truth across instances.
|
||||||
|
const MEMORY_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
// During `next build`, pages are prerendered and `Date.now()` is treated as an
|
||||||
|
// unstable prerender value — always serve the in-process cache then (settings
|
||||||
|
// cannot change mid-build). Runtime keeps the normal TTL check.
|
||||||
|
const IS_PRERENDER =
|
||||||
|
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||||
|
process.env.NEXT_PHASE === "phase-production-compile";
|
||||||
|
|
||||||
class SiteSettings {
|
class SiteSettings {
|
||||||
private cache: Map<string, string> | null = null;
|
private cache: { map: Map<string, string>; expiresAt: number } | null = null;
|
||||||
|
|
||||||
private async loadFromDb(): Promise<Map<string, string>> {
|
private async loadFromDb(): Promise<Map<string, string>> {
|
||||||
try {
|
try {
|
||||||
@@ -32,22 +43,31 @@ class SiteSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async load(): Promise<Map<string, string>> {
|
private async load(): Promise<Map<string, string>> {
|
||||||
|
if (this.cache !== null) {
|
||||||
|
if (IS_PRERENDER || this.cache.expiresAt > Date.now()) {
|
||||||
|
return this.cache.map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (redis) {
|
if (redis) {
|
||||||
try {
|
try {
|
||||||
const cached = await redis.get(REDIS_CACHE_KEY);
|
const cached = await redis.get(REDIS_CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const parsed = JSON.parse(cached) as Record<string, string>;
|
const parsed = JSON.parse(cached) as Record<string, string>;
|
||||||
return new Map(Object.entries(parsed));
|
const map = new Map(Object.entries(parsed));
|
||||||
|
this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS };
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn("Redis cache read failed for site settings");
|
logger.warn("Redis cache read failed for site settings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.cache !== null) return this.cache;
|
// Expired but usable fallback — keeps the site up if both Redis and DB fail.
|
||||||
|
if (this.cache !== null) return this.cache.map;
|
||||||
|
|
||||||
const map = await this.loadFromDb();
|
const map = await this.loadFromDb();
|
||||||
this.cache = map;
|
this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS };
|
||||||
|
|
||||||
if (redis) {
|
if (redis) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
"nitroClient": "Nitro client",
|
"nitroClient": "Nitro client",
|
||||||
"tagline": "Een online virtuele wereld waar je je eigen avatar kunt maken, vrienden kunt maken, kunt chatten, kamers kunt maken en nog veel meer!",
|
"tagline": "Een online virtuele wereld waar je je eigen avatar kunt maken, vrienden kunt maken, kunt chatten, kamers kunt maken en nog veel meer! Leeftijdsvereiste: 18+",
|
||||||
"login": "Inloggen",
|
"login": "Inloggen",
|
||||||
"or": "Of",
|
"or": "Of",
|
||||||
"createAccount": "Account aanmaken"
|
"createAccount": "Account aanmaken"
|
||||||
@@ -919,6 +919,7 @@
|
|||||||
"passwordError": "Wachtwoord moet minimaal 6 tekens bevatten.",
|
"passwordError": "Wachtwoord moet minimaal 6 tekens bevatten.",
|
||||||
"confirmPasswordError": "Wachtwoorden komen niet overeen.",
|
"confirmPasswordError": "Wachtwoorden komen niet overeen.",
|
||||||
"termsRequired": "Je moet de voorwaarden & regels accepteren.",
|
"termsRequired": "Je moet de voorwaarden & regels accepteren.",
|
||||||
|
"ageVerified": "Ik ben 18+ en ga akkoord met de leeftijdsvereiste.",
|
||||||
"usernameRequired": "Gebruikersnaam is verplicht.",
|
"usernameRequired": "Gebruikersnaam is verplicht.",
|
||||||
"emailRequired": "E-mail is verplicht.",
|
"emailRequired": "E-mail is verplicht.",
|
||||||
"passwordRequired": "Wachtwoord is verplicht.",
|
"passwordRequired": "Wachtwoord is verplicht.",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export interface ImportSingleResult {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
itemId?: number;
|
itemId?: number;
|
||||||
catalogItemId?: number | null;
|
catalogItemId?: number | null;
|
||||||
|
catalogUpdated?: boolean;
|
||||||
dimensions?: { x: number; y: number; z: number };
|
dimensions?: { x: number; y: number; z: number };
|
||||||
spriteCount?: number;
|
spriteCount?: number;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
// Modules under test transitively import @/env; skip its strict parse so
|
// Modules under test transitively import @/env; skip its strict parse so
|
||||||
// unit tests run without a populated .env.
|
// unit tests run without a populated .env.
|
||||||
env: { SKIP_ENV_VALIDATION: "1" },
|
env: { SKIP_ENV_VALIDATION: "1" },
|
||||||
|
testTimeout: 10000,
|
||||||
exclude: ["e2e/**", "node_modules/**", ".next/**", ".next.prev/**"],
|
exclude: ["e2e/**", "node_modules/**", ".next/**", ".next.prev/**"],
|
||||||
coverage: {
|
coverage: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
Reference in new issue
Block a user