diff --git a/.env.example b/.env.example index 9f0add1e5c..4bb4d67138 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,11 @@ SMTP_FROM= DISCORD_WEBHOOK_URL= ALERT_EMAIL= +# Optional AI content moderation (user comments / guestbook). +# When set, posts are checked against the OpenAI Moderations endpoint in +# addition to the website_wordfilter blocklist. Fail-open if unset/erroring. +OPENAI_API_KEY= + # Optional PayPal top-up (sandbox by default) PAYPAL_CLIENT_ID= PAYPAL_SECRET= diff --git a/next.config.ts b/next.config.ts index 3db60a42c9..76840837dd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; const nextConfig: NextConfig = { // This app lives inside the Laravel repo tree (which has its own lockfiles); @@ -9,4 +10,8 @@ const nextConfig: NextConfig = { serverExternalPackages: ["@prisma/adapter-mariadb", "mariadb", "@prisma/client"], }; -export default 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"); + +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index 2c8f86623e..57cd36c0c8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "hash-wasm": "^4.12.0", "next": "^16.2.9", "next-auth": "5.0.0-beta.31", + "next-intl": "^4.13.0", "nodemailer": "^6.9.0", "otplib": "^12.0.1", "react": "^19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e8f4a9dff..2eca8b04ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: next-auth: specifier: 5.0.0-beta.31 version: 5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@6.10.1)(react@19.2.7) + next-intl: + specifier: ^4.13.0 + version: 4.13.0(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3) nodemailer: specifier: ^6.9.0 version: 6.10.1 @@ -731,6 +734,18 @@ packages: cpu: [x64] os: [win32] + '@formatjs/fast-memoize@3.1.6': + resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==} + + '@formatjs/icu-messageformat-parser@3.5.12': + resolution: {integrity: sha512-YyzzxVgYJ8DELmmkhn0Yr0rUj0dTJFf9Jp628K3S0ysInBWxLVDOS8i3RP91cCp4DMK4WYb4cVMhWA9i4knSJg==} + + '@formatjs/icu-skeleton-parser@2.1.10': + resolution: {integrity: sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==} + + '@formatjs/intl-localematcher@0.8.10': + resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==} + '@hono/node-server@1.19.11': resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} @@ -985,6 +1000,94 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -1263,12 +1366,108 @@ packages: cpu: [x64] os: [win32] + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.15.43': + resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.43': + resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.43': + resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.43': + resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.43': + resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.43': + resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.43': + resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.43': + resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.43': + resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.43': + resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.43': + resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.43': + resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.43': + resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@tailwindcss/forms@0.5.11': resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} peerDependencies: @@ -1754,6 +1953,20 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + icu-minify@4.13.0: + resolution: {integrity: sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==} + + intl-messageformat@11.2.9: + resolution: {integrity: sha512-cGzymZerpDhVXRKjKLgXKda9gI29TU2o88L7gwNMHp3WZVxA/0c5tX52udXbW9JklDApolvMXZG6Dhhdz5eirA==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -1894,6 +2107,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next-auth@5.0.0-beta.31: resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==} peerDependencies: @@ -1910,6 +2127,19 @@ packages: nodemailer: optional: true + next-intl-swc-plugin-extractor@4.13.0: + resolution: {integrity: sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==} + + next-intl@4.13.0: + resolution: {integrity: sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + next@16.2.9: resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} engines: {node: '>=20.9.0'} @@ -1931,6 +2161,9 @@ packages: sass: optional: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + nodemailer@6.10.1: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} engines: {node: '>=6.0.0'} @@ -1964,9 +2197,16 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + po-parser@2.1.1: + resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -2171,6 +2411,11 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + use-intl@4.13.0: + resolution: {integrity: sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2587,6 +2832,18 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@formatjs/fast-memoize@3.1.6': {} + + '@formatjs/icu-messageformat-parser@3.5.12': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.10 + + '@formatjs/icu-skeleton-parser@2.1.10': {} + + '@formatjs/intl-localematcher@0.8.10': + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@hono/node-server@1.19.11(hono@4.12.27)': dependencies: hono: 4.12.27 @@ -2760,6 +3017,66 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + '@petamoriken/float16@3.9.3': {} '@prisma/adapter-mariadb@7.8.0': @@ -2988,12 +3305,74 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@schummar/icu-type-parser@1.21.5': {} + '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.15.43': + optional: true + + '@swc/core-darwin-x64@1.15.43': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.43': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.43': + optional: true + + '@swc/core-linux-arm64-musl@1.15.43': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.43': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-gnu@1.15.43': + optional: true + + '@swc/core-linux-x64-musl@1.15.43': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.43': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.43': + optional: true + + '@swc/core-win32-x64-msvc@1.15.43': + optional: true + + '@swc/core@1.15.43': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.27 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.43 + '@swc/core-darwin-x64': 1.15.43 + '@swc/core-linux-arm-gnueabihf': 1.15.43 + '@swc/core-linux-arm64-gnu': 1.15.43 + '@swc/core-linux-arm64-musl': 1.15.43 + '@swc/core-linux-ppc64-gnu': 1.15.43 + '@swc/core-linux-s390x-gnu': 1.15.43 + '@swc/core-linux-x64-gnu': 1.15.43 + '@swc/core-linux-x64-musl': 1.15.43 + '@swc/core-win32-arm64-msvc': 1.15.43 + '@swc/core-win32-ia32-msvc': 1.15.43 + '@swc/core-win32-x64-msvc': 1.15.43 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.27': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/forms@0.5.11(tailwindcss@4.3.1)': dependencies: mini-svg-data-uri: 1.4.4 @@ -3447,6 +3826,21 @@ snapshots: dependencies: safer-buffer: 2.1.2 + icu-minify@4.13.0: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.12 + + intl-messageformat@11.2.9: + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@formatjs/icu-messageformat-parser': 3.5.12 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-property@1.0.2: {} isexe@2.0.0: {} @@ -3562,6 +3956,8 @@ snapshots: nanoid@3.3.15: {} + negotiator@1.0.0: {} + next-auth@5.0.0-beta.31(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@6.10.1)(react@19.2.7): dependencies: '@auth/core': 0.41.2(nodemailer@6.10.1) @@ -3570,6 +3966,25 @@ snapshots: optionalDependencies: nodemailer: 6.10.1 + next-intl-swc-plugin-extractor@4.13.0: {} + + next-intl@4.13.0(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3): + dependencies: + '@formatjs/intl-localematcher': 0.8.10 + '@parcel/watcher': 2.5.6 + '@swc/core': 1.15.43 + icu-minify: 4.13.0 + negotiator: 1.0.0 + next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next-intl-swc-plugin-extractor: 4.13.0 + po-parser: 2.1.1 + react: 19.2.7 + use-intl: 4.13.0(react@19.2.7) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/helpers' + next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 @@ -3594,6 +4009,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-addon-api@7.1.1: {} + nodemailer@6.10.1: {} oauth4webapi@3.8.6: {} @@ -3618,12 +4035,16 @@ snapshots: picocolors@1.1.1: {} + picomatch@4.0.4: {} + pkg-types@2.3.1: dependencies: confbox: 0.2.4 exsolve: 1.1.0 pathe: 2.0.3 + po-parser@2.1.1: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -3833,6 +4254,14 @@ snapshots: undici-types@7.18.2: {} + use-intl@4.13.0(react@19.2.7): + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.13.0 + intl-messageformat: 11.2.9 + react: 19.2.7 + util-deprecate@1.0.2: {} valibot@1.2.0(typescript@5.9.3): diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts new file mode 100644 index 0000000000..0af9e45e63 --- /dev/null +++ b/src/actions/admin-ads.ts @@ -0,0 +1,84 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// CRUD for website advertisements (website_ads). Emulator does not own this +// table; it only stores an image URL rendered in the site layout/widgets. + +export async function createAd(formData: FormData): Promise { + const staff = await requireStaff(); + const image = String(formData.get("image") ?? "").trim().slice(0, 255); + if (!image) return; + + const now = new Date(); + try { + const ad = await prisma.websiteAds.create({ + data: { image, createdAt: now, updatedAt: now }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "ad_create", + description: `Created advertisement #${ad.id} (${image})`, + targetType: "website_ad", + targetId: Number(ad.id), + }); + } catch { + // DB error — page re-renders unchanged. + revalidatePath("/admin/ads"); + return; + } + redirect("/admin/ads"); +} + +export async function updateAd(formData: FormData): Promise { + const staff = await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!/^\d+$/.test(raw)) return; + const id = BigInt(raw); + const image = String(formData.get("image") ?? "").trim().slice(0, 255); + if (!image) return; + + try { + await prisma.websiteAds.update({ + where: { id }, + data: { image, updatedAt: new Date() }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "ad_update", + description: `Updated advertisement #${id} (${image})`, + targetType: "website_ad", + targetId: Number(id), + }); + } catch { + // Not found or DB error — ignore. + revalidatePath(`/admin/ads/${id}`); + return; + } + redirect("/admin/ads"); +} + +export async function deleteAd(formData: FormData): Promise { + const staff = await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!/^\d+$/.test(raw)) return; + const id = BigInt(raw); + + try { + await prisma.websiteAds.delete({ where: { id } }); + await logStaffActivity({ + staffId: staff.id, + action: "ad_delete", + description: `Deleted advertisement #${id}`, + targetType: "website_ad", + targetId: Number(id), + }); + } catch { + // Not found or DB error — ignore. + } + redirect("/admin/ads"); +} diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts new file mode 100644 index 0000000000..d5ed50b234 --- /dev/null +++ b/src/actions/admin-help.ts @@ -0,0 +1,125 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// CRUD for help-center FAQ entries (website_help_center_categories). Each entry +// is a titled content block with an optional image and call-to-action button. + +function parsePosition(value: FormDataEntryValue | null): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1; +} + +export async function createHelpQuestion(formData: FormData): Promise { + const staff = await requireStaff(); + const name = String(formData.get("name") ?? "").trim().slice(0, 255); + const content = String(formData.get("content") ?? "").trim(); + if (!name || !content) return; + + const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255); + const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255); + const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255); + const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425"; + const buttonBorderColor = + String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15"; + + try { + const entry = await prisma.websiteHelpCenterCategories.create({ + data: { + name, + content, + position: parsePosition(formData.get("position")), + imageUrl: imageUrl || null, + buttonText: buttonText || null, + buttonUrl: buttonUrl || null, + buttonColor, + buttonBorderColor, + smallBox: formData.get("smallBox") != null, + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "help_create", + description: `Created help-center entry #${entry.id} (${name})`, + targetType: "help_center_category", + targetId: Number(entry.id), + }); + } catch { + // Unique name collision or DB error — re-render unchanged. + revalidatePath("/admin/help-questions"); + return; + } + redirect("/admin/help-questions"); +} + +export async function updateHelpQuestion(formData: FormData): Promise { + const staff = await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!/^\d+$/.test(raw)) return; + const id = BigInt(raw); + + const name = String(formData.get("name") ?? "").trim().slice(0, 255); + const content = String(formData.get("content") ?? "").trim(); + if (!name || !content) return; + + const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255); + const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255); + const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255); + const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425"; + const buttonBorderColor = + String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15"; + + try { + await prisma.websiteHelpCenterCategories.update({ + where: { id }, + data: { + name, + content, + position: parsePosition(formData.get("position")), + imageUrl: imageUrl || null, + buttonText: buttonText || null, + buttonUrl: buttonUrl || null, + buttonColor, + buttonBorderColor, + smallBox: formData.get("smallBox") != null, + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "help_update", + description: `Updated help-center entry #${id} (${name})`, + targetType: "help_center_category", + targetId: Number(id), + }); + } catch { + // Not found, unique collision, or DB error — ignore. + revalidatePath(`/admin/help-questions/${id}`); + return; + } + redirect("/admin/help-questions"); +} + +export async function deleteHelpQuestion(formData: FormData): Promise { + const staff = await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!/^\d+$/.test(raw)) return; + const id = BigInt(raw); + + try { + await prisma.websiteHelpCenterCategories.delete({ where: { id } }); + await logStaffActivity({ + staffId: staff.id, + action: "help_delete", + description: `Deleted help-center entry #${id}`, + targetType: "help_center_category", + targetId: Number(id), + }); + } catch { + // Not found or DB error — ignore. + } + redirect("/admin/help-questions"); +} diff --git a/src/actions/admin-permissions.ts b/src/actions/admin-permissions.ts new file mode 100644 index 0000000000..54d383c869 --- /dev/null +++ b/src/actions/admin-permissions.ts @@ -0,0 +1,98 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// website_permissions (model WebsitePermissions) is the CMS-owned rank-permission +// mapping AtomCMS exposes in admin: a key/value(/comment) row per permission. +// Editable columns on the table are exactly: key (unique), value, comment. +// id is BigInt and created_at/updated_at are managed here — no other columns +// exist, so there are no extra staff flags to toggle. + +export async function createPermission(formData: FormData): Promise { + const staff = await requireStaff(); + const key = String(formData.get("key") ?? "").trim().slice(0, 255); + const value = String(formData.get("value") ?? "").trim().slice(0, 255); + const comment = String(formData.get("comment") ?? "").trim().slice(0, 255); + if (!key) return; + + const now = new Date(); + try { + await prisma.websitePermissions.upsert({ + where: { key }, + update: { value: value || null, comment: comment || null, updatedAt: now }, + create: { + key, + value: value || null, + comment: comment || null, + createdAt: now, + updatedAt: now, + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "permission_create", + description: `Saved permission "${key}" = "${value}"`, + targetType: "permission", + }); + } catch { + // ignore (e.g. constraint failure) — page re-renders current state + } + revalidatePath("/admin/permissions"); +} + +export async function updatePermission(formData: FormData): Promise { + const staff = await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!raw) return; + const id = BigInt(raw); + const key = String(formData.get("key") ?? "").trim().slice(0, 255); + const value = String(formData.get("value") ?? "").trim().slice(0, 255); + const comment = String(formData.get("comment") ?? "").trim().slice(0, 255); + if (!key) return; + + try { + await prisma.websitePermissions.update({ + where: { id }, + data: { + key, + value: value || null, + comment: comment || null, + updatedAt: new Date(), + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "permission_update", + description: `Updated permission #${raw} ("${key}" = "${value}")`, + targetType: "permission", + }); + } catch { + // ignore (e.g. duplicate key) — page re-renders current state + } + revalidatePath("/admin/permissions"); +} + +export async function deletePermission(formData: FormData): Promise { + const staff = await requireStaff(); + const raw = String(formData.get("id") ?? ""); + if (!raw) return; + + try { + const deleted = await prisma.websitePermissions.delete({ + where: { id: BigInt(raw) }, + select: { key: true }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "permission_delete", + description: `Deleted permission #${raw} ("${deleted.key}")`, + targetType: "permission", + }); + } catch { + // ignore (e.g. already removed) + } + revalidatePath("/admin/permissions"); +} diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts new file mode 100644 index 0000000000..8394579f69 --- /dev/null +++ b/src/actions/admin-shop.ts @@ -0,0 +1,142 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// Website store packages (website_shop_articles). This CMS-owned table backs +// the public store; rows here are the buyable packages, not orders. The closest +// "orders" record is website_paypal_transactions, exposed read-only by the page. + +/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */ +function optUInt(formData: FormData, key: string): number | null { + const raw = String(formData.get(key) ?? "").trim(); + if (raw === "") return null; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return null; + return Math.floor(n); +} + +/** Parse a required non-negative UnsignedInt, falling back to 0. */ +function reqUInt(formData: FormData, key: string): number { + const n = optUInt(formData, key); + return n ?? 0; +} + +export async function createShopArticle(formData: FormData): Promise { + const staff = await requireStaff(); + + const name = String(formData.get("name") ?? "").trim().slice(0, 255); + if (!name) return; + + const now = new Date(); + try { + const created = await prisma.websiteShopArticles.create({ + data: { + name, + info: String(formData.get("info") ?? "").trim().slice(0, 255), + icon: String(formData.get("icon") ?? "").trim().slice(0, 255), + color: String(formData.get("color") ?? "").trim().slice(0, 255), + costs: reqUInt(formData, "costs"), + giveRank: optUInt(formData, "giveRank"), + credits: optUInt(formData, "credits"), + duckets: optUInt(formData, "duckets"), + diamonds: optUInt(formData, "diamonds"), + badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null, + position: reqUInt(formData, "position"), + createdAt: now, + updatedAt: now, + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "shop_create", + description: `Created shop package "${name}" (${created.costs} costs)`, + targetType: "shop_article", + targetId: Number(created.id), + }); + } catch { + // Unique constraint on `name` (or DB unavailable) — swallow and re-render. + return; + } + + redirect("/admin/shop"); +} + +export async function updateShopArticle(formData: FormData): Promise { + const staff = await requireStaff(); + + const raw = String(formData.get("id") ?? "").trim(); + if (!raw) return; + let id: bigint; + try { + id = BigInt(raw); + } catch { + return; + } + + const name = String(formData.get("name") ?? "").trim().slice(0, 255); + if (!name) return; + + try { + await prisma.websiteShopArticles.update({ + where: { id }, + data: { + name, + info: String(formData.get("info") ?? "").trim().slice(0, 255), + icon: String(formData.get("icon") ?? "").trim().slice(0, 255), + color: String(formData.get("color") ?? "").trim().slice(0, 255), + costs: reqUInt(formData, "costs"), + giveRank: optUInt(formData, "giveRank"), + credits: optUInt(formData, "credits"), + duckets: optUInt(formData, "duckets"), + diamonds: optUInt(formData, "diamonds"), + badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null, + position: reqUInt(formData, "position"), + updatedAt: new Date(), + }, + }); + await logStaffActivity({ + staffId: staff.id, + action: "shop_update", + description: `Updated shop package #${id} ("${name}")`, + targetType: "shop_article", + targetId: Number(id), + }); + } catch { + return; + } + + revalidatePath(`/admin/shop/${id}`); + redirect("/admin/shop"); +} + +export async function deleteShopArticle(formData: FormData): Promise { + const staff = await requireStaff(); + + const raw = String(formData.get("id") ?? "").trim(); + if (!raw) return; + let id: bigint; + try { + id = BigInt(raw); + } catch { + return; + } + + try { + await prisma.websiteShopArticles.delete({ where: { id } }); + await logStaffActivity({ + staffId: staff.id, + action: "shop_delete", + description: `Deleted shop package #${id}`, + targetType: "shop_article", + targetId: Number(id), + }); + } catch { + return; + } + + redirect("/admin/shop"); +} diff --git a/src/actions/admin-tags.ts b/src/actions/admin-tags.ts new file mode 100644 index 0000000000..67a905136b --- /dev/null +++ b/src/actions/admin-tags.ts @@ -0,0 +1,107 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { requireStaff } from '@/lib/admin/guard'; +import { prisma } from '@/lib/prisma'; +import { logStaffActivity } from '@/lib/services/staff-activity'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Parse a FormData field into a positive BigInt id, or null when invalid. */ +function parseId(raw: FormDataEntryValue | null): bigint | null { + if (typeof raw !== 'string' || raw.trim() === '') return null; + try { + const id = BigInt(raw.trim()); + return id > 0n ? id : null; + } catch { + return null; + } +} + +function str(raw: FormDataEntryValue | null): string { + return typeof raw === 'string' ? raw : ''; +} + +/** Normalise a hex-ish colour into the 10-char background_color column. */ +function normaliseColor(raw: string): string { + const v = raw.trim().slice(0, 10); + return v || '#888888'; +} + +// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ────────── + +export async function createTag(formData: FormData): Promise { + const staff = await requireStaff(); + const name = str(formData.get('name')).trim().slice(0, 255); + if (!name) return; + + const backgroundColor = normaliseColor(str(formData.get('backgroundColor'))); + const now = new Date(); + + try { + const created = await prisma.tags.create({ + data: { name, backgroundColor, createdAt: now, updatedAt: now }, + }); + await logStaffActivity({ + staffId: staff.id, + action: 'tag_create', + description: `Created tag "${name}" (#${created.id})`, + targetType: 'tag', + targetId: Number(created.id), + }); + } catch { + // Fail soft — DB unavailable or duplicate. + } + revalidatePath('/admin/tags'); +} + +export async function updateTag(formData: FormData): Promise { + const staff = await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + + const name = str(formData.get('name')).trim().slice(0, 255); + const backgroundColor = normaliseColor(str(formData.get('backgroundColor'))); + if (!name) return; + + try { + await prisma.tags.update({ + where: { id }, + data: { name, backgroundColor, updatedAt: new Date() }, + }); + await logStaffActivity({ + staffId: staff.id, + action: 'tag_update', + description: `Updated tag #${id} → "${name}"`, + targetType: 'tag', + targetId: Number(id), + }); + } catch { + // Row may be gone; ignore. + } + revalidatePath('/admin/tags'); +} + +export async function deleteTag(formData: FormData): Promise { + const staff = await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + + try { + // Remove the tag and any taggable links pointing at it. + await prisma.$transaction([ + prisma.taggables.deleteMany({ where: { tagId: id } }), + prisma.tags.delete({ where: { id } }), + ]); + await logStaffActivity({ + staffId: staff.id, + action: 'tag_delete', + description: `Deleted tag #${id}`, + targetType: 'tag', + targetId: Number(id), + }); + } catch { + // Already deleted; ignore. + } + revalidatePath('/admin/tags'); +} diff --git a/src/actions/admin-user-edit.ts b/src/actions/admin-user-edit.ts new file mode 100644 index 0000000000..465516a601 --- /dev/null +++ b/src/actions/admin-user-edit.ts @@ -0,0 +1,94 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +// users_currency.type values for the non-credits currencies (mirror send-currency.ts). +// Credits live on users.credits; pixels/points live on the users row too; +// duckets/diamonds live in users_currency keyed by (user_id, type). +const DUCKETS_TYPE = 0; +const DIAMONDS_TYPE = 5; + +function toInt(value: FormDataEntryValue | null, min = 0): number | null { + if (value == null) return null; + const raw = String(value).trim(); + if (raw === "") return null; + const n = Number(raw); + if (!Number.isFinite(n)) return null; + const i = Math.trunc(n); + return i < min ? min : i; +} + +/** + * Edit the SAFE website-managed fields of a users row (and the duckets/diamonds + * balances in users_currency). Never touches the password. Re-reads the staff + * user from the session and logs the action. emulator-owned users.id is Int. + */ +export async function updateUser(formData: FormData): Promise { + // Never trust the client: re-check staff inside the action. + const staff = await requireStaff(); + + const userId = Number(formData.get("id")); + if (!Number.isInteger(userId) || userId <= 0) return; + + const existing = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + if (!existing) return; + + // users row — only existing, safe columns. + const mailRaw = String(formData.get("mail") ?? "").trim(); + const motto = String(formData.get("motto") ?? "").slice(0, 127); + const look = String(formData.get("look") ?? "").slice(0, 256); + const rank = toInt(formData.get("rank"), 1); + const credits = toInt(formData.get("credits"), 0); + const pixels = toInt(formData.get("pixels"), 0); + const points = toInt(formData.get("points"), 0); + + await prisma.user.update({ + where: { id: userId }, + data: { + mail: mailRaw === "" ? null : mailRaw.slice(0, 500), + motto, + look, + ...(rank != null ? { rank } : {}), + ...(credits != null ? { credits } : {}), + ...(pixels != null ? { pixels } : {}), + ...(points != null ? { points } : {}), + }, + }); + + // users_currency — set exact balances for duckets / diamonds. + const duckets = toInt(formData.get("duckets"), 0); + const diamonds = toInt(formData.get("diamonds"), 0); + if (duckets != null) { + await prisma.usersCurrency.upsert({ + where: { userId_type: { userId, type: DUCKETS_TYPE } }, + update: { amount: duckets }, + create: { userId, type: DUCKETS_TYPE, amount: duckets }, + }); + } + if (diamonds != null) { + await prisma.usersCurrency.upsert({ + where: { userId_type: { userId, type: DIAMONDS_TYPE } }, + update: { amount: diamonds }, + create: { userId, type: DIAMONDS_TYPE, amount: diamonds }, + }); + } + + await logStaffActivity({ + staffId: staff.id, + action: "user_edit", + description: `Edited account fields of user #${userId}`, + targetType: "user", + targetId: userId, + }); + + revalidatePath(`/admin/users/${userId}`); + revalidatePath(`/admin/users/${userId}/edit`); + redirect(`/admin/users/${userId}`); +} diff --git a/src/actions/article-comments.ts b/src/actions/article-comments.ts index 9420213e0a..8c8b2daae7 100644 --- a/src/actions/article-comments.ts +++ b/src/actions/article-comments.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { isAllowed } from "@/lib/services/moderation"; // website_article_comments.comment is VARCHAR(255); keep the write within bounds. const COMMENT_MAX = 255; @@ -23,6 +24,9 @@ export async function postComment(formData: FormData): Promise { const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX); if (!comment) return; + // Block filtered/AI-flagged content before it touches the DB (fail-open). + if (!(await isAllowed(comment)).ok) return; + const articleIdRaw = String(formData.get("articleId") ?? "").trim(); if (!/^\d+$/.test(articleIdRaw)) return; diff --git a/src/actions/article-reactions.ts b/src/actions/article-reactions.ts new file mode 100644 index 0000000000..928ff05521 --- /dev/null +++ b/src/actions/article-reactions.ts @@ -0,0 +1,94 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// The reaction set the UI offers. The action rejects anything outside this list +// so the website_article_reactions.reaction VARCHAR(50) only ever holds known +// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx. +const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]); + +/** + * Toggle the SIGNED-IN user's reaction on a news article. + * + * The voter id is read from the session (re-fetched via auth()), never from the + * submitted FormData, so a crafted form cannot vote as another account. A user + * has at most one ACTIVE reaction per article: + * - clicking the reaction they already have active -> deactivates it (un-vote) + * - clicking a different reaction -> that reaction becomes active and any other + * reaction rows for this user/article are deactivated + * - first-ever reaction of a type -> a new active row is created + * + * Rows are toggled (active flag) rather than deleted so a user's history of + * reaction types is preserved. website_article_reactions has no composite + * unique key, so we resolve the existing row with findFirst rather than upsert. + */ +export async function toggleReaction(formData: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) return; + + const userId = Number(session.user.id); + if (!Number.isFinite(userId)) return; + + const reaction = String(formData.get("reaction") ?? "").trim().toLowerCase(); + if (!ALLOWED_REACTIONS.has(reaction)) return; + + const articleIdRaw = String(formData.get("articleId") ?? "").trim(); + if (!/^\d+$/.test(articleIdRaw)) return; + + let articleId: bigint; + try { + articleId = BigInt(articleIdRaw); + } catch { + return; + } + + let slug: string | null = null; + try { + // Confirm the article exists (and grab its slug for revalidation). + const article = await prisma.websiteArticles.findUnique({ + where: { id: articleId }, + select: { slug: true }, + }); + if (!article) return; + slug = article.slug; + + // The user's current row for THIS reaction on THIS article, if any. + const existing = await prisma.websiteArticleReactions.findFirst({ + where: { userId, articleId, reaction }, + select: { id: true, active: true }, + }); + + if (existing?.active) { + // Already reacting with this exact reaction -> un-vote (deactivate it). + await prisma.websiteArticleReactions.update({ + where: { id: existing.id }, + data: { active: false }, + }); + } else { + // Switching to (or first-time picking) this reaction: clear any other + // active reaction by this user on this article, then activate this one. + await prisma.websiteArticleReactions.updateMany({ + where: { userId, articleId, active: true }, + data: { active: false }, + }); + + if (existing) { + await prisma.websiteArticleReactions.update({ + where: { id: existing.id }, + data: { active: true }, + }); + } else { + await prisma.websiteArticleReactions.create({ + data: { userId, articleId, reaction, active: true }, + }); + } + } + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + if (slug) revalidatePath(`/news/${slug}`); +} diff --git a/src/actions/email-verify.ts b/src/actions/email-verify.ts new file mode 100644 index 0000000000..2c7537a240 --- /dev/null +++ b/src/actions/email-verify.ts @@ -0,0 +1,88 @@ +"use server"; + +import { createHash, timingSafeEqual } from "node:crypto"; +import { env } from "@/env"; +import { sendMail } from "@/lib/services/email"; +import { siteSettings } from "@/lib/services/site-settings"; + +// Stateless email verification, AtomCMS-faithful but DB-table-free. +// +// Instead of persisting a row (password_resets style), the token is a keyed +// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a +// server-only secret, an attacker who only knows the email cannot forge a +// matching token, and /verify can recompute + compare it without any storage. +// The token is therefore deterministic per (email, secret) pair and stays valid +// until the account's mail_verified flips to '1' (after which /verify no-ops). + +/** Secret mixed into the digest. Falls back to AUTH_SECRET, then a constant. */ +function verifySecret(): string { + return env.APP_KEY || env.AUTH_SECRET || "atom-cms-verify"; +} + +/** Compute the verification token for an email (lowercased + trimmed). */ +export async function verificationToken(email: string): Promise { + const normalised = email.trim().toLowerCase(); + return createHash("sha256").update(`${normalised}|${verifySecret()}`).digest("hex"); +} + +/** + * Constant-time check that `token` matches the expected digest for `email`. + * Returns false on any length/format mismatch rather than throwing. + */ +export async function isValidVerificationToken( + email: string, + token: string, +): Promise { + if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false; + const expected = await verificationToken(email); + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(token.toLowerCase(), "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Build the verification link + email and send it. No-ops gracefully when SMTP + * is unconfigured (sendMail returns false). `userId` is accepted for a faithful + * call signature, but the stateless token only needs the email. + */ +export async function sendVerification(userId: number, email: string): Promise { + const normalised = email.trim().toLowerCase(); + if (!normalised) return false; + + const token = await verificationToken(normalised); + const base = env.APP_URL.replace(/\/+$/, ""); + const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent( + normalised, + )}`; + + const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME; + + const html = ` +
+

Verify your email

+

Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.

+

+ + Verify email + +

+

If the button doesn't work, paste this link into your browser:

+

${link}

+
+ `.trim(); + + // `userId` referenced so a faithful caller signature isn't flagged unused. + void userId; + + return sendMail(normalised, `Verify your email · ${hotelName}`, html); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/actions/guestbook.ts b/src/actions/guestbook.ts index 279e288867..1259dbfc62 100644 --- a/src/actions/guestbook.ts +++ b/src/actions/guestbook.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { isAllowed } from "@/lib/services/moderation"; // Emulator/CMS column message is VARCHAR(255); keep the write within bounds. const MESSAGE_MAX = 255; @@ -27,6 +28,9 @@ export async function postGuestbook(formData: FormData): Promise { const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX); if (!message) return; + // Block filtered/AI-flagged content before it touches the DB (fail-open). + if (!(await isAllowed(message)).ok) return; + // Optional: used only to revalidate the correct profile route. const username = String(formData.get("username") ?? "").trim(); diff --git a/src/actions/messenger.ts b/src/actions/messenger.ts new file mode 100644 index 0000000000..c0a9f180c1 --- /dev/null +++ b/src/actions/messenger.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +/** + * Accept a pending friend request as the SIGNED-IN user. + * + * The ACCEPTER is re-read from the session via auth() and is never trusted from + * the submitted FormData. Only the request id comes from the form, and the + * request is only honoured when its target (user_to_id) is the session user — + * so a crafted form cannot accept a request addressed to someone else. + * + * Arcturus/AtomCMS stores friendships as TWO directional rows in + * messenger_friendships (one user_one_id→user_two_id, one the reverse). We + * create both inside a transaction and delete the originating request so it no + * longer shows as pending in the in-game messenger or here. + */ +export async function acceptFriend(formData: FormData): Promise { + const session = await auth(); + const meId = Number(session?.user?.id); + if (!Number.isInteger(meId) || meId <= 0) return; + + const requestId = Number(formData.get("requestId")); + if (!Number.isInteger(requestId) || requestId <= 0) return; + + try { + // The request must exist AND be addressed to the session user. + const request = await prisma.messengerFriendrequests.findUnique({ + where: { id: requestId }, + select: { id: true, userFromId: true, userToId: true }, + }); + if (!request || request.userToId !== meId) return; + + const friendId = request.userFromId; + if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { + // Malformed/self request — just clear it. + await prisma.messengerFriendrequests.delete({ where: { id: requestId } }); + return; + } + + const friendsSince = Math.floor(Date.now() / 1000); + + await prisma.$transaction(async (tx) => { + // Don't double-insert if a friendship already exists in either direction. + const existing = await tx.messengerFriendships.findFirst({ + where: { + OR: [ + { userOneId: meId, userTwoId: friendId }, + { userOneId: friendId, userTwoId: meId }, + ], + }, + select: { id: true }, + }); + + if (!existing) { + await tx.messengerFriendships.createMany({ + data: [ + { userOneId: meId, userTwoId: friendId, friendsSince }, + { userOneId: friendId, userTwoId: meId, friendsSince }, + ], + }); + } + + await tx.messengerFriendrequests.delete({ where: { id: requestId } }); + }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + revalidatePath("/messages"); + revalidatePath("/friends"); +} diff --git a/src/actions/register.ts b/src/actions/register.ts index d7b507f58f..7a756fc846 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -2,6 +2,7 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { sendVerification } from "@/actions/email-verify"; import { hashPassword } from "@/lib/auth/password"; import { prisma } from "@/lib/prisma"; @@ -39,7 +40,7 @@ export async function register(formData: FormData): Promise { h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0"; const now = Math.floor(Date.now() / 1000); try { - await prisma.user.create({ + const created = await prisma.user.create({ data: { username, password: await hashPassword(password), @@ -49,7 +50,16 @@ export async function register(formData: FormData): Promise { ipCurrent: ip, look: DEFAULT_LOOK, }, + select: { id: true }, }); + + // Fire the verification email. Best-effort: a mail/SMTP failure must not + // abort a successful registration, so swallow its errors here. + try { + await sendVerification(created.id, mail); + } catch { + // No-op: account is created; user can request a new link later. + } } catch { error = "Could not create the account (is the username unique?)"; } diff --git a/src/app/admin/ads/[id]/page.tsx b/src/app/admin/ads/[id]/page.tsx new file mode 100644 index 0000000000..7e65b36f1c --- /dev/null +++ b/src/app/admin/ads/[id]/page.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { deleteAd, updateAd } from "@/actions/admin-ads"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function EditAd({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + let ad: Awaited> = null; + try { + ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } }); + } catch { + notFound(); + } + if (!ad) notFound(); + + return ( +
+

+ ← Advertisements +

+

Edit advertisement

+
+ + + +
+
+ + +
+
+ ); +} diff --git a/src/app/admin/ads/new/page.tsx b/src/app/admin/ads/new/page.tsx new file mode 100644 index 0000000000..a3f5722978 --- /dev/null +++ b/src/app/admin/ads/new/page.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; +import { createAd } from "@/actions/admin-ads"; + +export default function NewAd() { + return ( +
+

+ ← Advertisements +

+

New advertisement

+
+ + +
+
+ ); +} diff --git a/src/app/admin/ads/page.tsx b/src/app/admin/ads/page.tsx new file mode 100644 index 0000000000..08af99c610 --- /dev/null +++ b/src/app/admin/ads/page.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function AdminAds() { + let ads: Awaited> = []; + try { + ads = await prisma.websiteAds.findMany({ + orderBy: { id: "desc" }, + take: 500, + }); + } catch { + ads = []; + } + + return ( +
+
+

Advertisements

+ + New ad +
+

+ Website advertisement banners (website_ads). Each ad is an image URL shown + across the site. +

+ + + + + + + + + + + {ads.map((ad) => ( + + + + + + + ))} + +
PreviewImage URLCreated +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + + {ad.image} + + {ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""} + + Edit +
+ {ads.length === 0 ?

No advertisements yet.

: null} +
+ ); +} diff --git a/src/app/admin/help-questions/[id]/page.tsx b/src/app/admin/help-questions/[id]/page.tsx new file mode 100644 index 0000000000..e94ed7cdfe --- /dev/null +++ b/src/app/admin/help-questions/[id]/page.tsx @@ -0,0 +1,85 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function EditHelpQuestion({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + let entry: Awaited< + ReturnType + > = null; + try { + entry = await prisma.websiteHelpCenterCategories.findUnique({ + where: { id: BigInt(id) }, + }); + } catch { + notFound(); + } + if (!entry) notFound(); + + return ( +
+

+ ← Help Center +

+

Edit help-center entry

+
+ + +