diff --git a/drizzle/migrations/0022_add_terms_age_consent.sql b/drizzle/migrations/0022_add_terms_age_consent.sql new file mode 100644 index 00000000..202e0109 --- /dev/null +++ b/drizzle/migrations/0022_add_terms_age_consent.sql @@ -0,0 +1,4 @@ +-- Add terms/age consent columns expected by the users schema (register flow). +ALTER TABLE `users` + ADD COLUMN `terms_accepted` TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN `age_verified` TINYINT(1) NOT NULL DEFAULT 0; diff --git a/next.config.ts b/next.config.ts index f7e5868d..0ee59b6f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,21 +1,14 @@ -import { execFileSync } from "node:child_process"; -import withBundleAnalyzer from "@next/bundle-analyzer"; +import { execSync } from "node:child_process"; import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; -function resolveDeploymentId(): string | undefined { - const configuredId = process.env.NEXT_DEPLOYMENT_ID?.trim(); - if (configuredId) return configuredId; - +const getGitCommit = () => { try { - return execFileSync("git", ["rev-parse", "HEAD"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); + return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); } catch { - return process.env.APP_VERSION?.trim() || undefined; + return undefined; } -} +}; const securityHeaders = [ { key: "X-DNS-Prefetch-Control", value: "on" }, @@ -30,16 +23,15 @@ const securityHeaders = [ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", }, - // CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts). ]; const nextConfig: NextConfig = { - cacheComponents: true, - deploymentId: resolveDeploymentId(), + deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(), + reactStrictMode: true, + compress: true, + productionBrowserSourceMaps: false, serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"], - // The /admin/import hub was consolidated into the catalogue studio at - // /admin/studio. Redirect the old routes so bookmarks/links don't 404. async redirects() { return [ { @@ -100,7 +92,6 @@ const nextConfig: NextConfig = { ]; }, - // Silence Turbopack warnings for the broad file patterns in src/lib. turbopack: { ignoreIssue: [ { @@ -109,27 +100,16 @@ const nextConfig: NextConfig = { ], }, - typescript: { - ignoreBuildErrors: false, - }, - - // Compress responses with gzip/brotli. - compress: true, - - // Disable Next.js telemetry and browser sourcemaps in production. - productionBrowserSourceMaps: false, - experimental: { + optimizePackageImports: ["lucide-react", "date-fns"], useTypeScriptCli: true, hideLogsAfterAbort: true, }, - // Optimize images served through next/image with sharp → AVIF/WebP. images: { formats: ["image/avif", "image/webp"], }, - // Add caching headers for static assets async headers() { return [ { @@ -145,6 +125,28 @@ const nextConfig: NextConfig = { }, ], }, + { + // Nitro client payload (~2.8GB across swf/nitro-assets): without + // caching every client open re-downloads hundreds of files. + // Fresh for 7 days, then serve stale + revalidate in background + // so asset updates still propagate without blocking players. + source: "/swf/(.*)", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=604800, stale-while-revalidate=2592000", + }, + ], + }, + { + source: "/nitro-assets/(.*)", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=604800, stale-while-revalidate=2592000", + }, + ], + }, { source: "/images/(.*)", headers: [{ key: "Cache-Control", value: "public, max-age=86400" }], @@ -153,14 +155,6 @@ const nextConfig: NextConfig = { }, }; -// next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via -// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged. const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); -const config = withNextIntl(nextConfig); - -const withBA = withBundleAnalyzer({ - enabled: process.env.ANALYZE === "true", -}); - -export default withBA(config); +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index e078fd07..8c73c3bf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "engines": { "node": ">=26.7.0 <27" }, - "packageManager": "pnpm@11.22.0", + "packageManager": "pnpm@11.24.0", "scripts": { "dev": "next dev", "build": "next build", @@ -34,7 +34,6 @@ "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "@hookform/resolvers": "5.9.1", - "@next/bundle-analyzer": "16.3.2", "@tanstack/react-virtual": "3.14.10", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -49,7 +48,7 @@ "jszip": "3.10.1", "lenis": "1.3.26", "lucide-react": "1.33.0", - "lzma": "2.3.2", + "lzma": "^2.3.2", "motion": "13.1.1", "music-metadata": "11.15.0", "mysql2": "3.23.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fcebb17..34fdd9f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,9 +37,6 @@ importers: '@hookform/resolvers': specifier: 5.9.1 version: 5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3) - '@next/bundle-analyzer': - specifier: 16.3.2 - version: 16.3.2 '@tanstack/react-virtual': specifier: 3.14.10 version: 3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -83,7 +80,7 @@ importers: specifier: 1.33.0 version: 1.33.0(react@19.2.8) lzma: - specifier: 2.3.2 + specifier: ^2.3.2 version: 2.3.2 motion: specifier: 13.1.1 @@ -327,10 +324,6 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@discoveryjs/json-ext@0.5.7': - resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} - engines: {node: '>=10.0.0'} - '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -825,9 +818,6 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/bundle-analyzer@16.3.2': - resolution: {integrity: sha512-1WSK3fjvlZzA3cpP6XsM9JW2/LDWWlldlYN5sL/h2kvAQCu2KeXLxNkqfI/0ac+ikOYFzWuuBpL9O0Cu3mrfww==} - '@next/env@16.3.2': resolution: {integrity: sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==} @@ -1224,9 +1214,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -1901,15 +1888,6 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} - - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} - engines: {node: '>=0.4.0'} - hasBin: true - aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1967,10 +1945,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - content-type@2.1.0: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} @@ -1996,9 +1970,6 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - debounce@1.2.1: - resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2118,9 +2089,6 @@ packages: sqlite3: optional: true - duplexer@0.1.2: - resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2136,10 +2104,6 @@ packages: engines: {node: '>=18'} hasBin: true - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2209,10 +2173,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gzip-size@6.0.0: - resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} - engines: {node: '>=10'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2262,10 +2222,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-plain-object@5.1.0: - resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==} - engines: {node: '>=0.10.0'} - is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -2536,10 +2492,6 @@ packages: react-dom: optional: true - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2633,10 +2585,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - opener@1.5.2: - resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} - hasBin: true - otplib@13.4.1: resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==} @@ -2826,10 +2774,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - sirv@2.0.4: - resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} - engines: {node: '>= 10'} - smol-toml@1.8.0: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} @@ -2943,10 +2887,6 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3092,11 +3032,6 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} - webpack-bundle-analyzer@4.10.1: - resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} - engines: {node: '>= 10.13.0'} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3108,18 +3043,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.13: - resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} - engines: {node: '>=8.3.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3217,8 +3140,6 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@discoveryjs/json-ext@0.5.7': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.8)': dependencies: react: 19.2.8 @@ -3526,13 +3447,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/bundle-analyzer@16.3.2': - dependencies: - webpack-bundle-analyzer: 4.10.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@next/env@16.3.2': {} '@next/swc-darwin-arm64@16.3.2': @@ -3770,8 +3684,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@polka/url@1.0.0-next.29': {} - '@radix-ui/primitive@1.1.7': {} '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': @@ -4271,12 +4183,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 - acorn-walk@8.3.5: - dependencies: - acorn: 8.18.0 - - acorn@8.18.0: {} - aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4325,8 +4231,6 @@ snapshots: colorette@2.0.20: {} - commander@7.2.0: {} - content-type@2.1.0: {} convert-source-map@2.0.0: {} @@ -4341,8 +4245,6 @@ snapshots: dateformat@4.6.3: {} - debounce@1.2.1: {} - debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 @@ -4370,8 +4272,6 @@ snapshots: optionalDependencies: mysql2: 3.23.4(@types/node@26.2.0) - duplexer@0.1.2: {} - end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -4412,8 +4312,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - escape-string-regexp@4.0.0: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -4475,10 +4373,6 @@ snapshots: graceful-fs@4.2.11: {} - gzip-size@6.0.0: - dependencies: - duplexer: 0.1.2 - has-flag@4.0.0: {} hash-wasm@4.12.0: {} @@ -4525,8 +4419,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-plain-object@5.1.0: {} - is-property@1.0.2: {} isarray@1.0.0: {} @@ -4737,8 +4629,6 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - mrmime@2.0.1: {} - ms@2.1.3: {} music-metadata@11.15.0(supports-color@7.2.0): @@ -4839,8 +4729,6 @@ snapshots: dependencies: wrappy: 1.0.2 - opener@1.5.2: {} - otplib@13.4.1: dependencies: '@otplib/core': 13.4.1 @@ -5107,12 +4995,6 @@ snapshots: siginfo@2.0.0: {} - sirv@2.0.4: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - smol-toml@1.8.0: {} sonic-boom@4.2.1: @@ -5198,8 +5080,6 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - totalist@3.0.1: {} - tslib@2.8.1: {} tsx@4.23.12: @@ -5311,25 +5191,6 @@ snapshots: walk-up-path@4.0.0: {} - webpack-bundle-analyzer@4.10.1: - dependencies: - '@discoveryjs/json-ext': 0.5.7 - acorn: 8.18.0 - acorn-walk: 8.3.5 - commander: 7.2.0 - debounce: 1.2.1 - escape-string-regexp: 4.0.0 - gzip-size: 6.0.0 - html-escaper: 2.0.2 - is-plain-object: 5.1.0 - opener: 1.5.2 - picocolors: 1.1.1 - sirv: 2.0.4 - ws: 7.5.13 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -5339,8 +5200,6 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.13: {} - yaml@2.9.0: {} zod@4.4.3: {} diff --git a/scripts/furni-diagnose-now.ts b/scripts/furni-diagnose-now.ts index 9232e38b..43402953 100644 --- a/scripts/furni-diagnose-now.ts +++ b/scripts/furni-diagnose-now.ts @@ -97,7 +97,7 @@ async function main(): Promise { const dbByClass = new Map(baseRows.map((r) => [r.itemName, r.id])); const dbById = new Map(baseRows.map((r) => [r.id, r.itemName])); let fdWrongId = 0; - let fdIdConflict = 0; // id belongs to a different classname in DB + let _fdIdConflict = 0; // id belongs to a different classname in DB let fdMissingInDb = 0; const fdExamplesWrong: string[] = []; for (const e of entries) { @@ -114,7 +114,7 @@ async function main(): Promise { ); continue; } - if (dbById.get(e.id) !== e.classname) fdIdConflict++; + if (dbById.get(e.id) !== e.classname) _fdIdConflict++; } console.log( ` furnidata entries whose id != items_base.id for same classname: ${fdWrongId}`, diff --git a/scripts/sync-nitro-urls.cjs b/scripts/sync-nitro-urls.cjs new file mode 100644 index 00000000..1b98cb4b --- /dev/null +++ b/scripts/sync-nitro-urls.cjs @@ -0,0 +1,104 @@ +// Syncs the Nitro/Octane runtime config URLs from environment variables into the +// renderer-config.json / renderer-config.jsonc / ui-config.json files. +// +// Uses jsonc-parser so JSONC (with // and /* */ comments) is handled safely — +// unlike a naive JSON.parse/json.load this never corrupts URLs that contain +// "https://". After this runs the files are written back as strict JSON (which +// is still valid JSONC), so downstream validation passes. +// +// Env: +// NITRO_SRC_DIR e.g. /var/www/Octane/public/configuration +// NITRO_DIST_CONFIG_DIR e.g. /var/www/Octane/dist/configuration +// NITRO_GAMEDATA_CONF_DIR e.g. /var/www/Gamedata/config +// NITRO_IMAGE_LIBRARY_URL, NITRO_HOF_FURNITURE_URL, NITRO_API_URL, +// NITRO_SOCKET_URL, NITRO_GAMEDATA_URL, NITRO_ASSET_URL, +// NITRO_FURNI_ASSET_ICON_URL +const fs = require("node:fs"); +const path = require("node:path"); +const { parse, modify } = require("jsonc-parser"); + +const FILES = [ + "renderer-config.json", + "renderer-config.jsonc", + "ui-config.json", +]; + +function env(name) { + return process.env[name]?.length ? process.env[name] : null; +} + +function applyOverrides(data) { + const image = env("NITRO_IMAGE_LIBRARY_URL"); + const hof = env("NITRO_HOF_FURNITURE_URL"); + const api = env("NITRO_API_URL"); + const socket = env("NITRO_SOCKET_URL"); + const gamedata = env("NITRO_GAMEDATA_URL"); + const asset = env("NITRO_ASSET_URL"); + const icon = env("NITRO_FURNI_ASSET_ICON_URL"); + + const set = (key, value) => { + if (value && !(key in data)) data[key] = value; + if (value) data[key] = value; + }; + + set("image.library.url", image); + set("hof.furni.url", hof); + set("api.url", api); + set("socket.url", socket); + set("gamedata.url", gamedata); + set("asset.url", asset); + set("furni.asset.icon.url", icon); + + if (gamedata) { + data["radio.url"] = `${gamedata}/config/radio-stations.jsonc?t=%timestamp%`; + data["soundboard.url"] = + `${gamedata}/config/soundboard-sounds.jsonc?t=%timestamp%`; + } + if ("show.google.ads" in data) data["show.google.ads"] = false; + return data; +} + +const dirs = [ + env("NITRO_SRC_DIR"), + env("NITRO_DIST_CONFIG_DIR"), + env("NITRO_GAMEDATA_CONF_DIR"), +].filter(Boolean); + +let changed = 0; +for (const dir of dirs) { + if (!fs.existsSync(dir)) continue; + for (const file of FILES) { + const full = path.join(dir, file); + if (!fs.existsSync(full)) continue; + let content; + try { + content = fs.readFileSync(full, "utf-8"); + } catch { + continue; + } + let data; + try { + data = parse(content, [], { + allowTrailingComma: true, + allowEmptyContent: true, + }); + } catch (e) { + console.error(`[sync-nitro-urls] parse error in ${full}: ${e}`); + continue; + } + if (!data || typeof data !== "object") continue; + const before = JSON.stringify(data); + const updated = applyOverrides(data); + if (JSON.stringify(updated) === before) continue; + try { + fs.writeFileSync(full, `${JSON.stringify(updated, null, 4)}\n`); + changed++; + console.log(` [OK] Synced URLs: ${file} (${dir})`); + } catch (e) { + console.error(`[sync-nitro-urls] write error ${full}: ${e}`); + } + } +} + +console.log(` [OK] URL sync complete (${changed} file(s) updated)`); +process.exit(0); diff --git a/src/actions/register.ts b/src/actions/register.ts index 5e4f9c51..3a25946e 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -1,10 +1,11 @@ "use server"; import { count, eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; +import { after } from "next/server"; import { z } from "zod"; import { sendVerification } from "@/actions/email-verify"; import { hashPassword } from "@/lib/auth/password"; +import { invalidateKey } from "@/lib/cached-db"; import { db, User } from "@/lib/db"; import { logger } from "@/lib/logger"; import { clientIp, rateLimit } from "@/lib/rate-limit"; @@ -29,16 +30,23 @@ const registerSchema = z.object({ .regex(/[A-Z]/, "Password must contain at least one uppercase letter") .regex(/[a-z]/, "Password must contain at least one lowercase letter") .regex(/[0-9]/, "Password must contain at least one digit"), + passwordConfirmation: z.string(), look: z.string().optional(), }); // A valid starter Habbo figure so the avatar renders in-client immediately. const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; +export interface RegisterState { + error: string | null; + ok: boolean; +} + export async function register( - _prevState: string | null, + _prevState: RegisterState, formData: FormData, -): Promise { +): Promise { + const fail = (error: string): RegisterState => ({ error, ok: false }); const raw = { username: String(formData.get("username") ?? "") .normalize("NFC") @@ -48,6 +56,9 @@ export async function register( .trim() .toLowerCase(), password: String(formData.get("password") ?? "").normalize("NFC"), + passwordConfirmation: String( + formData.get("password_confirmation") ?? "", + ).normalize("NFC"), look: String(formData.get("look") ?? "") .normalize("NFC") @@ -57,7 +68,11 @@ export async function register( const parsed = registerSchema.safeParse(raw); if (!parsed.success) { - return parsed.error.issues[0]?.message ?? "Invalid input"; + return fail(parsed.error.issues[0]?.message ?? "Invalid input"); + } + + if (parsed.data.password !== parsed.data.passwordConfirmation) { + return fail("Passwords do not match"); } const { username, mail, password, look } = parsed.data; @@ -66,7 +81,9 @@ export async function register( // Throttle sign-ups per IP (5 per 10 minutes) to curb account spam. if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) { - return "Too many sign-up attempts. Please wait a few minutes and try again."; + return fail( + "Too many sign-up attempts. Please wait a few minutes and try again.", + ); } // CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings. @@ -74,18 +91,18 @@ export async function register( if (cfg.provider !== "none") { const token = String(formData.get(cfg.field) ?? "").normalize("NFC"); if (!(await verifyCaptcha(token, ip))) - return "Captcha verification failed. Please try again."; + return fail("Captcha verification failed. Please try again."); } // Terms acceptance check. if (!raw.termsAccepted) - return "You must accept the terms and conditions to register."; + return fail("You must accept the terms and conditions to register."); // VPN/proxy block (only when enabled in /admin/vpn). if ((await checkVpn(ip)).blocked) { - return ( + return fail( (await siteSettings.get("vpn_block_message", "")) || - "Registrations from VPN/proxy connections are not allowed." + "Registrations from VPN/proxy connections are not allowed.", ); } @@ -98,7 +115,9 @@ export async function register( .where(eq(User.ipRegister, ip)) .catch(() => [{ total: 0 }]); if (Number(row?.total ?? 0) >= max) - return "You have reached the maximum number of accounts for your connection."; + return fail( + "You have reached the maximum number of accounts for your connection.", + ); } // Uniqueness check. @@ -108,10 +127,10 @@ export async function register( .from(User) .where(eq(User.username, username)) .limit(1); - if (existing) return "That username is already taken"; + if (existing) return fail("That username is already taken"); } catch { logger.warn("Username uniqueness check failed during registration"); - return "Registration is temporarily unavailable"; + return fail("Registration is temporarily unavailable"); } const now = Math.floor(Date.now() / 1000); @@ -124,19 +143,38 @@ export async function register( ipRegister: ip, ipCurrent: ip, look, + termsAccepted: raw.termsAccepted, }); + } catch (err) { + const code = (err as { cause?: { code?: string } }).cause?.code; + if (code === "ER_DUP_ENTRY") { + return fail("That username is already taken"); + } + logger.error("Account creation failed", { + code, + message: err instanceof Error ? err.message : String(err), + }); + return fail( + "Could not create the account. Please try again or contact staff.", + ); + } - if (hasEmail) { + // The login lookup is cached for 15s — drop any stale entry so the + // immediate auto sign-in sees the fresh row. + await invalidateKey(`login:user:${username}`); + + // Verification email must never block the sign-up response — it is sent + // after the response is flushed (no-op when mail is unconfigured). + if (hasEmail) { + after(async () => { try { await sendVerification(mail); } catch { logger.warn("Failed to send verification email after registration"); } - } - } catch { - logger.warn("Account creation failed"); - return "Could not create the account (is the username unique?)"; + }); } - redirect("/login?registered=1"); + // Client auto signs in with these credentials and navigates to /me. + return { error: null, ok: true }; } diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index a65eecb5..660eb7c9 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -1,4 +1,4 @@ -import { and, asc, count, desc, eq, gt, inArray, or } from "drizzle-orm"; +import { and, asc, count, desc, eq, gt, or } from "drizzle-orm"; import Image from "next/image"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -17,7 +17,6 @@ import { UserReferrals, UsersBadges, UsersSettings, - WebsiteLoginLogs, } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; import { resolveHotelName } from "@/lib/hotel-name"; @@ -58,10 +57,7 @@ const ERROR_MESSAGES: Record = { }; function getErrorMessage(error: string): string { - if (error === "not_enough") return ERROR_MESSAGES.not_enough; - if (error === "no_referrals") return ERROR_MESSAGES.no_referrals; - if (error === "bad_config") return ERROR_MESSAGES.bad_config; - return ERROR_MESSAGES.error; + return ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error; } export default async function MePage({ @@ -90,10 +86,11 @@ export default async function MePage({ alertRaw, recentRooms, badges, - lastWebLoginRows, userSettingsRows, friendCountRows, unreadCountRows, + referralsRows, + friends, ] = await Promise.all([ db .select({ @@ -139,13 +136,6 @@ export default async function MePage({ .where(and(eq(UsersBadges.userId, userId), gt(UsersBadges.slotId, 0))) .orderBy(asc(UsersBadges.slotId)) .catch(() => []), - db - .select({ createdAt: WebsiteLoginLogs.createdAt }) - .from(WebsiteLoginLogs) - .where(eq(WebsiteLoginLogs.userId, userId)) - .orderBy(desc(WebsiteLoginLogs.id)) - .limit(1) - .catch(() => []), db .select({ achievementScore: UsersSettings.achievementScore, @@ -170,18 +160,51 @@ export default async function MePage({ .from(MessengerOffline) .where(eq(MessengerOffline.userId, userId)) .catch(() => [{ value: 0 }]), + db + .select({ referralsTotal: UserReferrals.referralsTotal }) + .from(UserReferrals) + .where(eq(UserReferrals.userId, userId)) + .orderBy(desc(UserReferrals.id)) + .limit(1) + .catch(() => []), + db + .select({ + id: User.id, + username: User.username, + look: User.look, + motto: User.motto, + online: User.online, + }) + .from(MessengerFriendships) + .innerJoin( + User, + or( + and( + eq(MessengerFriendships.userOneId, userId), + eq(User.id, MessengerFriendships.userTwoId), + ), + and( + eq(MessengerFriendships.userTwoId, userId), + eq(User.id, MessengerFriendships.userOneId), + ), + ), + ) + .where( + or( + eq(MessengerFriendships.userOneId, userId), + eq(MessengerFriendships.userTwoId, userId), + ), + ) + .catch(() => []), ]); const user = userRows[0] ?? null; - const lastWebLogin = lastWebLoginRows[0] ?? null; + if (!user) throw new Error("user-not-found"); + const userSettings = userSettingsRows[0] ?? null; const friendCount = Number(friendCountRows[0]?.value ?? 0); const unreadCount = Number(unreadCountRows[0]?.value ?? 0); - void lastWebLogin; - - if (!user) throw new Error("user-not-found"); - const needed = Number.parseInt(neededRaw ?? "5", 10) || 5; const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0; const rewardCurrency = ( @@ -192,53 +215,12 @@ export default async function MePage({ .trim() .toLowerCase(); - const [referrals] = await db - .select({ referralsTotal: UserReferrals.referralsTotal }) - .from(UserReferrals) - .where(eq(UserReferrals.userId, userId)) - .orderBy(desc(UserReferrals.id)) - .limit(1) - .catch(() => []); - const referralTotal = referrals ? Number(referrals.referralsTotal) : 0; + const referralTotal = referralsRows[0] + ? Number(referralsRows[0].referralsTotal) + : 0; const canClaim = referralTotal >= needed; const remaining = Math.max(0, needed - referralTotal); - const friendships = await db - .select({ - userOneId: MessengerFriendships.userOneId, - userTwoId: MessengerFriendships.userTwoId, - }) - .from(MessengerFriendships) - .where( - or( - eq(MessengerFriendships.userOneId, userId), - eq(MessengerFriendships.userTwoId, userId), - ), - ) - .catch(() => []); - - const friendIds = Array.from( - new Set( - friendships - .map((f) => (f.userOneId === userId ? f.userTwoId : f.userOneId)) - .filter((id) => id && id !== userId), - ), - ); - - const friends = friendIds.length - ? await db - .select({ - id: User.id, - username: User.username, - look: User.look, - motto: User.motto, - online: User.online, - }) - .from(User) - .where(inArray(User.id, friendIds)) - .catch(() => []) - : []; - const onlineFriends = friends.filter((f) => f.online === "1"); const registered = new Date(user.accountCreated * 1000) @@ -255,6 +237,7 @@ export default async function MePage({ const avatarFull = avatarImageUrl(user.look, { direction: 2, }); + content = (
{claimed ? ( @@ -298,6 +281,7 @@ export default async function MePage({ /> rows[0]?.total ?? 0), ).catch(() => 0), - db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.online, "1")) - .limit(8) - .catch(() => []), - db - .select({ username: User.username, look: User.look }) - .from(User) - .orderBy(desc(User.accountCreated)) - .limit(8) - .catch(() => []), + cached("register_online_users", 10_000, () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.online, "1")) + .limit(8), + ).catch(() => []), + cached("register_latest_users", 30_000, () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .orderBy(desc(User.accountCreated)) + .limit(8), + ).catch(() => []), ]); return ( diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx index cfc9065b..d0ae5cc0 100644 --- a/src/app/client/client-view.tsx +++ b/src/app/client/client-view.tsx @@ -34,7 +34,13 @@ function ToolbarBtn({ } as const; if (href) { return ( - + {children} ); @@ -377,9 +383,12 @@ export function ClientView({