13 Commits
Author SHA1 Message Date
remco 2c26e4bebb chore(deps): update All dependencies
CI / check (pull_request) Failing after 11s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-15 11:00:27 +00:00
openhands e31f6d985c Add rollback/undo functionality for furniture imports
CI / check (push) Successful in 52s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m8s
- Added LogsFurniImports table to track import history with rollback support
- Implemented rollbackFurniImport() service function to revert imports:
  - Removes items from items_base, catalog_items, FurnitureData.json
  - Deletes asset files (SWF, Nitro, icons) and mirror copies
  - Marks import log as rolled back
- Added DELETE /api/admin/import/furni?importId=X endpoint
- Added dry-run support for import validation (?dryrun=1)
- Added updateExisting option to update existing catalog entries
- All TypeScript/Biome checks pass, tests pass (806 passed, 1 pre-existing skip)
2026-08-14 19:42:01 +02:00
openhands 7ef5038a9e Add updateExisting option to furniture import
CI / check (push) Successful in 1m14s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m15s
- Added updateExisting parameter to importSingleFurni and API routes
- When updateExisting=true, existing items are updated (catalog price/page) instead of failing
- Added catalogUpdated flag to ImportSingleResult
- Updates existing catalog entries (price, page) instead of skipping duplicates
2026-08-14 18:35:00 +02:00
openhands f89b8a6adf Fix FurnitureData.json spriteId conflict auto-repair
CI / check (push) Successful in 1m21s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m17s
- Added spriteId conflict resolution to repairFurniData(): keeps first classname per id, removes conflicting entries
- Returns new removedIdConflicts count in repair result
- Updated audit event type and client UI to display removedIdConflicts
- Updated catalog-audit.ts event type and client state type
- When 'Repair FurnitureData.json' is checked, it now fixes id conflicts automatically
2026-08-14 18:23:00 +02:00
openhands 3b6ebe7bdc Fix audit client: send checkFurniDataIds in request body
CI / check (push) Successful in 1m8s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m13s
- Added checkFurniDataIds to POST body so the 'Check FurnitureData.json for spriteId conflicts' checkbox actually works
- Added checkFurniDataIds to useCallback dependency array for correct React hook behavior
2026-08-14 18:06:24 +02:00
openhands a810fba0ae Increase vitest testTimeout to 10s for full-suite reliability
CI / check (push) Successful in 56s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m23s
- Prevents deploy-workflow-contract.test.ts timeout in full-suite runs
- Test runs in ~2s individually but hits 5s default under 30s+ import time
- 10s provides sufficient headroom under resource contention
2026-08-14 17:56:43 +02:00
openhands 27a3652a79 Improve terms checkbox accessibility and clarity
CI / check (push) Successful in 55s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m13s
- Added onKeyDown handler for keyboard accessibility (a11y)
- Added 'Required for account creation' note below checkbox
- Maintains existing onClick handler for mouse users
- All TypeScript and Biome checks pass
2026-08-14 17:21:35 +02:00
openhands 3d89e108f3 Fix terms acceptance enforcement in register
CI / check (push) Failing after 1m13s
CI / release (push) Skipped
CI / deploy (push) Skipped
- Add termsAccepted to raw form data object
- Check if terms were accepted before DB insert
- Return error if terms not accepted
- All TypeScript and Biome checks pass
2026-08-14 17:10:40 +02:00
openhands 0f35bc8529 Add hCaptcha support alongside Turnstile and reCAPTCHA
CI / check (push) Successful in 1m9s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m6s
- Add hcaptcha_site_key and hcaptcha to captcha_provider options in admin settings
- Update captcha.ts server-side verification for hCaptcha (new endpoint + secret key)
- Add hCaptcha widget rendering in register-form.tsx
- All TypeScript and Biome checks pass
2026-08-14 17:04:34 +02:00
openhands 1bca9657fa Remove age verification checkboxes, keep Turnstile CAPTCHA
CI / check (push) Successful in 54s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m16s
- Remove age verification (18+) checkboxes from register form
- Terms checkbox remains
- Turnstile CAPTCHA stays further down in form
- All TypeScript and biome checks pass
2026-08-14 16:57:17 +02:00
openhands 361ff56d65 Fix register form: checkboxes side by side with a11y support, improved text visibility
CI / check (push) Successful in 41s
CI / release (push) Skipped
CI / deploy (push) Failing after 1m50s
- Terms and age verification checkboxes now side by side
- Added onKeyDown handlers for keyboard navigation (a11y)
- Improved text clarity with explicit var(--color-text-readable) usage
- All TypeScript and biome checks pass
2026-08-14 16:44:22 +02:00
openhands e76c530e4f Fix TypeScript errors and implement furniture import ID integrity with 18+ age verification
CI / check (push) Successful in 29s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m2s
- Add termsAccepted and ageVerified columns to User table
- Update register schema with new boolean fields
- Fix register form age verification checkbox (th -> t)
- Fix furni-import spriteId declaration order
- Fix batch route variable naming (id -> spriteId)
- Fix catalog-audit import path and ensure correct types
- Hardened import with per-item id conflict checks
- Added audit option for FurnitureData.json spriteId conflicts
- Updated tagline to include Leeftijdsvereiste: 18+
2026-08-14 16:37:00 +02:00
openhands e5ec3c1f06 perf: optimize CMS queries, caching, and asset delivery
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s
Database:
- Add missing indexes (users.credits, users_currency(type,amount),
  users_settings.respects_received, camera_web.timestamp,
  messenger_offline.user_id) via migrations 0020/0021
- Use partial .select() everywhere instead of SELECT * (tickets, users,
  rooms, audit logs, catalog tree, polls, radio, password reset)
- Add queryPrepared/queryPreparedOne (server-side prepared statements)
  and switch the login check to a prepared statement; drop dead
  cache options from the pool config
- Raise total_users/total_rooms COUNT(*) cache TTL to 5m

Caching:
- Consolidate the three cache helpers (cached, redisCache, cachedQuery)
  into a single memory-first implementation backed by Redis
- invalidateKey now clears the in-process cache as well as Redis
- Cache homepage sections, news list, and leaderboard tabs; share one
  news_list cache key between homepage and news archive
- siteSettings: in-process cache with TTL so repeated getters no longer
  pay a Redis round-trip per call
- Share a 10s poll cache across all radio SSE connections
- Normalize timestamps after cache reads (Redis JSON round-trip)

Assets:
- Enable AVIF/WebP via images.formats and remove unoptimized from news
  covers and the homepage hero (149KB jpg) with proper sizes/priority
- Support ?format=webp|avif|png in the /imaging proxy via sharp

Other:
- Fix pnpm supply-chain minimumReleaseAge failures by excluding the
  freshly-published packages (next 16.3.1, hookform resolvers 5.8.0,
  resend 6.20.0)
- Remove unused before/after fields from housekeeping AuditEntry
2026-08-14 11:20:37 +02:00
49 changed files with 1129 additions and 464 deletions

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 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+73 -72
View File
@@ -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
+13
View File
@@ -19,21 +19,34 @@ minimumReleaseAgeExclude:
- "@biomejs/[email protected]" - "@biomejs/[email protected]"
- "@biomejs/[email protected]" - "@biomejs/[email protected]"
- "@biomejs/[email protected]" - "@biomejs/[email protected]"
- "@hookform/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "@next/[email protected]" - "@next/[email protected]"
- "@next/[email protected]"
- "[email protected]" - "[email protected]"
- "[email protected]" - "[email protected]"
- "[email protected]" - "[email protected]"
- "[email protected]" - "[email protected]"
- "[email protected]" - "[email protected]"
- "[email protected]"
- "[email protected]"
- "[email protected]" - "[email protected]"
- "[email protected]" - "[email protected]"
-26
View File
@@ -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");
}
+30 -55
View File
@@ -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");
}); });
+12 -13
View File
@@ -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";
} }
+4 -1
View File
@@ -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
View File
@@ -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));
+5
View File
@@ -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
View File
@@ -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);
+38 -71
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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}
+3 -14
View File
@@ -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
View File
@@ -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">
-2
View File
@@ -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;
} }
+25 -1
View File
@@ -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>
)} )}
+12 -1
View File
@@ -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",
+22 -2
View File
@@ -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)),
+58 -5
View File
@@ -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))
+4
View File
@@ -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({
+3
View File
@@ -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,
+6 -1
View File
@@ -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);
+39 -3
View File
@@ -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": "*",
+9 -4
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
/** /**
+9 -1
View File
@@ -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))
+25 -7
View File
@@ -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);
+64 -1
View File
@@ -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?.({
+35 -6
View File
@@ -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 };
}); });
} }
+11 -1
View File
@@ -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();
+87
View File
@@ -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)
+1
View File
@@ -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", () => ({
+230 -11
View File
@@ -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 };
}
+57
View File
@@ -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 [];
}
}
+17 -2
View File
@@ -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 =
+24 -4
View File
@@ -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 {
+2 -1
View File
@@ -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.",
+1
View File
@@ -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[];
+1
View File
@@ -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,