Close the web-feasible 100% gaps: REST write/token API, tickets, draw-badge, /me, sanitisation, dusk, radio SSE
Final parity push (web-tier only): - REST API write + token auth: POST /api/tokens (issue a personal_access_token for the session user), Bearer auth via src/lib/api-auth.ts, POST /api/articles/[slug]/comment, GET/DELETE /api/me/tokens, full tickets API (/api/tickets +[id] +[id]/reply), radio current-dj/points/points-leaderboard/ embed-config + POST shouts, and a real-time /api/radio/stream (SSE). 31 public API routes total. - Pages: /draw-badge (buy a custom profile badge → credits + RCON), /me dashboard (stats + online friends + referral claim). Wired into the nav. - HTML sanitisation (sanitize-html) — the HTMLPurifier equivalent — applied to writeable boxes + article bodies before dangerouslySetInnerHTML. - "Dusk" dark theme preset + a default-dark site option honoured by the no-flash boot script. Verified live (prod, amx_test): token issue → Bearer endpoint 200, no-token 401; /api/me/tokens lists it; current-dj/leaderboard JSON; /me + /draw-badge 200; reverted the test user + tokens. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
1 parent
8cedf5614e
commit
f7b3845131
30 files changed
+1734
-10
No files matched your search
@@ -30,6 +30,7 @@
|
|||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"sanitize-html": "^2.17.5",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@types/nodemailer": "^6.4.0",
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
|
|||||||
Generated
+114
@@ -44,6 +44,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7([email protected])
|
version: 19.2.7([email protected])
|
||||||
|
sanitize-html:
|
||||||
|
specifier: ^2.17.5
|
||||||
|
version: 2.17.5
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.0
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -69,6 +72,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.3(@types/[email protected])
|
version: 19.2.3(@types/[email protected])
|
||||||
|
'@types/sanitize-html':
|
||||||
|
specifier: ^2.16.1
|
||||||
|
version: 2.16.1
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.0
|
specifier: ^16.4.0
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
@@ -1593,6 +1599,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
|
||||||
|
|
||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||||
|
|
||||||
@@ -1701,6 +1710,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1718,6 +1730,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
|
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||||
|
|
||||||
@@ -1732,6 +1748,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1847,6 +1876,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -1879,6 +1916,10 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
@@ -1942,6 +1983,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
||||||
|
|
||||||
@@ -1967,6 +2011,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||||
|
|
||||||
@@ -1987,6 +2035,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2177,6 +2228,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2288,6 +2342,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
@@ -3476,6 +3533,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
htmlparser2: 10.1.0
|
||||||
|
|
||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
@@ -3596,6 +3657,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -3604,6 +3667,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3612,6 +3677,24 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
entities: 4.5.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
dom-serializer: 2.0.0
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3642,6 +3725,10 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3759,6 +3846,8 @@ snapshots:
|
|||||||
'@esbuild/win32-ia32': 0.28.1
|
'@esbuild/win32-ia32': 0.28.1
|
||||||
'@esbuild/win32-x64': 0.28.1
|
'@esbuild/win32-x64': 0.28.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -3816,6 +3905,13 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
entities: 7.0.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -3841,6 +3937,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3853,6 +3951,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
dayjs: 1.11.21
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4023,6 +4125,8 @@ snapshots:
|
|||||||
'@otplib/preset-default': 12.0.1
|
'@otplib/preset-default': 12.0.1
|
||||||
'@otplib/preset-v11': 12.0.1
|
'@otplib/preset-v11': 12.0.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -4150,6 +4254,16 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
deepmerge: 4.3.1
|
||||||
|
escape-string-regexp: 4.0.0
|
||||||
|
htmlparser2: 10.1.0
|
||||||
|
is-plain-object: 5.0.0
|
||||||
|
launder: 1.7.1
|
||||||
|
parse-srcset: 1.0.2
|
||||||
|
postcss: 8.5.15
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { sendCurrency } from "@/lib/services/send-currency";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buy a published community-drawn badge for the SIGNED-IN user. Faithful to
|
||||||
|
* AtomCMS's DrawBadgeController buy flow:
|
||||||
|
* - the buyer id is re-read from the session (auth()), NEVER from FormData,
|
||||||
|
* so a crafted form can't purchase on another account;
|
||||||
|
* - only PUBLISHED badges are purchasable;
|
||||||
|
* - the price is a flat, configurable amount (website_settings → the same
|
||||||
|
* `drawbadge.price` key AtomCMS uses), with a safe default;
|
||||||
|
* - the buyer must hold at least `price` credits, which are then deducted;
|
||||||
|
* - the badge is granted live via the emulator (givebadge RCON) and persisted
|
||||||
|
* into users_badges so it survives a relog (mirrors admin giveBadge).
|
||||||
|
*
|
||||||
|
* website_drawbadges has no price/code columns — the price comes from settings
|
||||||
|
* and the emulator badge code is derived from the badge's stored `badge_path`
|
||||||
|
* (the sprite filename, e.g. `album1584/MYBADGE.gif` → `MYBADGE`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_PRICE = 50;
|
||||||
|
|
||||||
|
// The emulator badge code is the badge_path filename without its directory or
|
||||||
|
// extension, restricted to the code charset the client accepts.
|
||||||
|
function badgeCodeFromPath(badgePath: string): string {
|
||||||
|
const base = badgePath.split(/[\\/]/).pop() ?? badgePath;
|
||||||
|
const noExt = base.replace(/\.[^.]+$/, "");
|
||||||
|
return noExt.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePrice(): Promise<number> {
|
||||||
|
const raw = await siteSettings.get("drawbadge.price", String(DEFAULT_PRICE));
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_PRICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buyBadge(formData: FormData): Promise<void> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
if (!Number.isFinite(userId)) redirect("/login");
|
||||||
|
|
||||||
|
// The form posts the badge row id; everything else (price, code) is resolved
|
||||||
|
// server-side from trusted data — never from the client.
|
||||||
|
const rawId = String(formData.get("id") ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||||
|
|
||||||
|
let outcome: "bought" | "invalid" | "credits" | "fail" = "fail";
|
||||||
|
let boughtCode = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||||
|
where: { id: BigInt(rawId) },
|
||||||
|
select: { id: true, badgePath: true, published: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!badge || !badge.published) {
|
||||||
|
outcome = "invalid";
|
||||||
|
} else {
|
||||||
|
const code = badgeCodeFromPath(badge.badgePath);
|
||||||
|
if (code.length === 0) {
|
||||||
|
outcome = "invalid";
|
||||||
|
} else {
|
||||||
|
const price = await resolvePrice();
|
||||||
|
|
||||||
|
// Re-read the buyer's live credit balance and verify it covers the cost.
|
||||||
|
const buyer = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { credits: true },
|
||||||
|
});
|
||||||
|
if (!buyer || buyer.credits < price) {
|
||||||
|
outcome = "credits";
|
||||||
|
} else {
|
||||||
|
// Deduct first, then grant. sendCurrency falls back to a direct DB
|
||||||
|
// write when RCON is offline; a negative amount is not supported, so
|
||||||
|
// the debit is an atomic credits decrement and the credit (grant) is
|
||||||
|
// the badge itself.
|
||||||
|
if (price > 0) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { credits: { decrement: price } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant the badge live so it appears immediately for online users.
|
||||||
|
await rcon.giveBadge(userId, code);
|
||||||
|
|
||||||
|
// Persist it so it survives a relog / offline grant. users_badges has
|
||||||
|
// no unique (user_id, badge_code) constraint, so guard duplicates and
|
||||||
|
// compute the next free slot ourselves (mirrors admin giveBadge).
|
||||||
|
try {
|
||||||
|
const existing = await prisma.usersBadges.findFirst({
|
||||||
|
where: { userId, badgeCode: code },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
const max = await prisma.usersBadges.aggregate({
|
||||||
|
where: { userId },
|
||||||
|
_max: { slotId: true },
|
||||||
|
});
|
||||||
|
const slotId = (max._max.slotId ?? 0) + 1;
|
||||||
|
await prisma.usersBadges.create({
|
||||||
|
data: { userId, slotId, badgeCode: code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort: the RCON grant already succeeded for online users.
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome = "bought";
|
||||||
|
boughtCode = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
outcome = "fail";
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/draw-badge");
|
||||||
|
|
||||||
|
// redirect() throws — it must live OUTSIDE the try/catch.
|
||||||
|
if (outcome === "bought") {
|
||||||
|
redirect(`/draw-badge?bought=${encodeURIComponent(boughtCode)}`);
|
||||||
|
}
|
||||||
|
redirect(`/draw-badge?error=${outcome}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp } from "@/lib/rate-limit";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim the referral reward for the SIGNED-IN user. Faithful to AtomCMS's
|
||||||
|
* ReferralController::__invoke:
|
||||||
|
* - the user id is re-read from the session (auth()), NEVER from FormData,
|
||||||
|
* so a crafted form cannot claim on another account;
|
||||||
|
* - the user must have referred at least `referrals_needed` people
|
||||||
|
* (user_referrals.referrals_total >= needed), otherwise it's rejected;
|
||||||
|
* - on success `referrals_total` is decremented by the threshold, the
|
||||||
|
* configured reward is granted, and the claim is logged.
|
||||||
|
*
|
||||||
|
* The reward amount/currency are CMS-configurable via website_settings
|
||||||
|
* (referral_reward_amount + referral_reward_currency_type). If the currency
|
||||||
|
* setting is not one of the four known wallets we keep the claim conservative
|
||||||
|
* and fail rather than guessing — no currency is moved.
|
||||||
|
*
|
||||||
|
* Errors redirect back to /me with a machine-readable ?error= code; success
|
||||||
|
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
|
||||||
|
* internal control-flow throw is never swallowed.
|
||||||
|
*/
|
||||||
|
const VALID_CURRENCIES = new Set<CurrencyName>([
|
||||||
|
"credits",
|
||||||
|
"duckets",
|
||||||
|
"diamonds",
|
||||||
|
"points",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function claimReferral(_formData: FormData): Promise<void> {
|
||||||
|
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" =
|
||||||
|
"error";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
if (!Number.isFinite(userId) || userId <= 0) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||||
|
// 5 referrals needed, 30 diamonds reward.
|
||||||
|
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||||
|
prisma.websiteSetting
|
||||||
|
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } })
|
||||||
|
.catch(() => null),
|
||||||
|
prisma.websiteSetting
|
||||||
|
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } })
|
||||||
|
.catch(() => null),
|
||||||
|
// The seeded key is referral_reward_currency_type; fall back to the
|
||||||
|
// shorter referral_reward_currency name if that is what is configured.
|
||||||
|
prisma.websiteSetting
|
||||||
|
.findFirst({
|
||||||
|
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } },
|
||||||
|
select: { value: true },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||||
|
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||||
|
const currency = (currencyRaw?.value ?? "diamonds").trim().toLowerCase() as CurrencyName;
|
||||||
|
|
||||||
|
// The user's referral tally lives in user_referrals (one row per user).
|
||||||
|
const referrals = await prisma.userReferrals
|
||||||
|
.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true, referralsTotal: true },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||||
|
|
||||||
|
if (!referrals || total <= 0) {
|
||||||
|
outcome = "no_referrals";
|
||||||
|
} else if (total < needed) {
|
||||||
|
outcome = "not_enough";
|
||||||
|
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||||
|
// Misconfigured reward — keep it conservative and grant nothing.
|
||||||
|
outcome = "bad_config";
|
||||||
|
} else {
|
||||||
|
// Spend the threshold first so a concurrent double-submit can't claim
|
||||||
|
// twice off the same balance, then deliver the reward and log it.
|
||||||
|
await prisma.userReferrals.update({
|
||||||
|
where: { id: referrals.id },
|
||||||
|
data: { referralsTotal: { decrement: needed } },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||||
|
} catch {
|
||||||
|
// sendCurrency already falls back to a direct DB write; if it still
|
||||||
|
// throws the spend stands. Roll the threshold back so the user isn't
|
||||||
|
// charged for an undelivered reward.
|
||||||
|
await prisma.userReferrals
|
||||||
|
.update({
|
||||||
|
where: { id: referrals.id },
|
||||||
|
data: { referralsTotal: { increment: needed } },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
outcome = "error";
|
||||||
|
throw new Error("currency-delivery-failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.claimedReferralLogs
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
ipAddress: await clientIp(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Best-effort audit log; the reward already landed.
|
||||||
|
});
|
||||||
|
|
||||||
|
outcome = "claimed";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||||
|
// navigation actually happens instead of being treated as a failure.
|
||||||
|
if (
|
||||||
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"digest" in err &&
|
||||||
|
typeof (err as { digest?: unknown }).digest === "string" &&
|
||||||
|
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||||
|
) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (outcome === "claimed") outcome = "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/me");
|
||||||
|
|
||||||
|
if (outcome === "claimed") {
|
||||||
|
redirect("/me?claimed=1");
|
||||||
|
}
|
||||||
|
redirect(`/me?error=${outcome}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Public REST API — post a comment on an article as the Bearer-authed user.
|
||||||
|
//
|
||||||
|
// POST /api/articles/:slug/comment — looks up the website_article by slug for
|
||||||
|
// its id, then inserts a website_article_comments row owned by the user behind
|
||||||
|
// the Authorization: Bearer token. Comment is required, non-empty, max 255
|
||||||
|
// chars (matches the VARCHAR(255) column). Fails soft — never returns a 500 for
|
||||||
|
// DB issues, just a generic error envelope.
|
||||||
|
|
||||||
|
import { apiError, apiJson } from "@/lib/api";
|
||||||
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const uid = await bearerUserId(req);
|
||||||
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { comment?: unknown };
|
||||||
|
const comment = typeof body.comment === "string" ? body.comment.trim() : "";
|
||||||
|
if (!comment) {
|
||||||
|
return apiError("Comment is required", 422);
|
||||||
|
}
|
||||||
|
if (comment.length > 255) {
|
||||||
|
return apiError("Comment may not be longer than 255 characters", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const article = await prisma.websiteArticles.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!article) {
|
||||||
|
return apiError("Article not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.websiteArticleComments.create({
|
||||||
|
data: {
|
||||||
|
articleId: article.id,
|
||||||
|
userId: uid,
|
||||||
|
comment,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiJson({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return apiError("Could not post comment", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Public REST API — manage the SIGNED-IN user's personal access tokens.
|
||||||
|
//
|
||||||
|
// GET /api/me/tokens — list the current user's tokens (id, name,
|
||||||
|
// lastUsedAt). The token hash is NEVER returned.
|
||||||
|
// DELETE /api/me/tokens?id=42 — revoke one of the current user's tokens.
|
||||||
|
//
|
||||||
|
// Auth is the NextAuth web session (auth()), not a Bearer token. Tokens belong
|
||||||
|
// to the user via personal_access_tokens.tokenable_id (a BigInt). NOTE: the live
|
||||||
|
// table has no expires_at column, so it is never read or written here.
|
||||||
|
|
||||||
|
import { apiError, apiJson } from "@/lib/api";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function currentUserId(): Promise<number | null> {
|
||||||
|
const session = await auth();
|
||||||
|
const id = session?.user?.id ? Number(session.user.id) : null;
|
||||||
|
return id && !Number.isNaN(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: Request) {
|
||||||
|
const id = await currentUserId();
|
||||||
|
if (!id) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await prisma.personalAccessTokens.findMany({
|
||||||
|
where: { tokenableId: BigInt(id) },
|
||||||
|
select: { id: true, name: true, lastUsedAt: true },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
});
|
||||||
|
// Never expose the token hash.
|
||||||
|
return apiJson({ data: tokens });
|
||||||
|
} catch {
|
||||||
|
return apiJson({ data: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
const id = await currentUserId();
|
||||||
|
if (!id) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
const tokenId = new URL(req.url).searchParams.get("id");
|
||||||
|
if (!tokenId || !/^\d+$/.test(tokenId)) {
|
||||||
|
return apiError("A valid token id is required", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Scope the delete to the owner so users cannot revoke others' tokens.
|
||||||
|
const result = await prisma.personalAccessTokens.deleteMany({
|
||||||
|
where: { id: BigInt(tokenId), tokenableId: BigInt(id) },
|
||||||
|
});
|
||||||
|
if (result.count === 0) {
|
||||||
|
return apiError("Token not found", 404);
|
||||||
|
}
|
||||||
|
return apiJson({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return apiError("Could not revoke token", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
// Current on-air DJ. The DJ is set manually via the radio_current_dj_id setting
|
||||||
|
// (Manual DJ user ID). When set, resolve that user's username/look; otherwise
|
||||||
|
// there is no DJ on air. Mirrors the AtomCMS radio "on air" widget.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(_req: Request) {
|
||||||
|
try {
|
||||||
|
const raw = await siteSettings.get("radio_current_dj_id", "");
|
||||||
|
const id = Number(raw);
|
||||||
|
|
||||||
|
// No DJ configured (empty / non-numeric / zero).
|
||||||
|
if (!raw || !Number.isFinite(id) || id <= 0) {
|
||||||
|
return apiJson({ dj: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { username: true, look: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return apiJson({ dj: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson({ dj: { username: user.username, look: user.look } });
|
||||||
|
} catch {
|
||||||
|
// DB / settings unavailable — no DJ rather than a 500.
|
||||||
|
return apiJson({ dj: null }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Minimal radio_* settings an external page needs to embed the player: stream
|
||||||
|
// URL, display name, whether the radio is enabled, and autoplay. Returned as a
|
||||||
|
// flat { key: value } map. None of these keys are secrets.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const EMBED_KEYS = [
|
||||||
|
"radio_enabled",
|
||||||
|
"radio_name",
|
||||||
|
"radio_stream_url",
|
||||||
|
"radio_auto_play",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET(_req: Request) {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.websiteSetting.findMany({
|
||||||
|
where: { key: { in: EMBED_KEYS } },
|
||||||
|
select: { key: true, value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
config[row.key] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson(config);
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — serve an empty config rather than a 500.
|
||||||
|
return apiJson({}, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Radio listener-points leaderboard: the top 20 users by total points, summed
|
||||||
|
// across radio_listener_points and joined to users for username/look. Public
|
||||||
|
// (no auth) — mirrors the AtomCMS radio leaderboard widget.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(_req: Request) {
|
||||||
|
try {
|
||||||
|
// Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
|
||||||
|
// aggregate ordering, then resolve the top 20 to usernames/looks.
|
||||||
|
const grouped = await prisma.radioListenerPoints.groupBy({
|
||||||
|
by: ["userId"],
|
||||||
|
_sum: { points: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ranked = grouped
|
||||||
|
.map((g) => ({ userId: g.userId, points: g._sum.points ?? 0 }))
|
||||||
|
.sort((a, b) => b.points - a.points)
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
if (ranked.length === 0) {
|
||||||
|
return apiJson({ data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = ranked.map((r) => r.userId);
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, username: true, look: true },
|
||||||
|
});
|
||||||
|
const userById = new Map(users.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
const data = ranked.map((r) => {
|
||||||
|
const u = userById.get(r.userId);
|
||||||
|
return {
|
||||||
|
username: u?.username ?? null,
|
||||||
|
look: u?.look ?? null,
|
||||||
|
points: r.points,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiJson({ data });
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — serve an empty leaderboard rather than a 500.
|
||||||
|
return apiJson({ data: [] }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { apiJson, apiError } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
// The Bearer-authed user's total radio listener points: the sum of all
|
||||||
|
// radio_listener_points.points rows for that user_id.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const uid = await bearerUserId(req);
|
||||||
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agg = await prisma.radioListenerPoints.aggregate({
|
||||||
|
where: { userId: uid },
|
||||||
|
_sum: { points: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiJson({ points: agg._sum.points ?? 0 });
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — report zero rather than a 500.
|
||||||
|
return apiJson({ points: 0 }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { apiJson } from "@/lib/api";
|
import { apiJson, apiError } from "@/lib/api";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
|
|
||||||
// Latest 50 radio shouts with their author's username/look resolved. Mirrors the
|
// Latest 50 radio shouts with their author's username/look resolved. Mirrors the
|
||||||
// query behind the public /radio/shouts page (radio_shouts ordered by created_at
|
// query behind the public /radio/shouts page (radio_shouts ordered by created_at
|
||||||
// desc, then joined to users by user_id).
|
// desc, then joined to users by user_id).
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Max shout length (radio_shouts.message is TEXT; cap to keep posts sane).
|
||||||
|
const MAX_MESSAGE_LENGTH = 255;
|
||||||
|
|
||||||
export async function GET(_req: Request) {
|
export async function GET(_req: Request) {
|
||||||
try {
|
try {
|
||||||
const shouts = await prisma.radioShouts.findMany({
|
const shouts = await prisma.radioShouts.findMany({
|
||||||
@@ -42,3 +46,37 @@ export async function GET(_req: Request) {
|
|||||||
return apiJson({ shouts: [] }, { status: 200 });
|
return apiJson({ shouts: [] }, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post a new radio shout as the Bearer-authed user into radio_shouts.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const uid = await bearerUserId(req);
|
||||||
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { message?: unknown };
|
||||||
|
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return apiError("Message is required", 422);
|
||||||
|
}
|
||||||
|
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
return apiError(`Message must be at most ${MAX_MESSAGE_LENGTH} characters`, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.radioShouts.create({
|
||||||
|
data: {
|
||||||
|
userId: BigInt(uid),
|
||||||
|
message,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiJson({ ok: true });
|
||||||
|
} catch {
|
||||||
|
// DB write failed — fail soft rather than a 500.
|
||||||
|
return apiError("Could not post shout", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { fetchListeners, fetchNowPlaying } from "@/lib/services/radio";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-Sent Events stream of live radio state (AtomCMS's radio SSE endpoint).
|
||||||
|
* Pushes { nowPlaying, listeners } every ~10s so players/widgets get real-time
|
||||||
|
* updates without polling. Closes cleanly when the client disconnects.
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (closed) return;
|
||||||
|
const [nowPlaying, listeners] = await Promise.all([
|
||||||
|
fetchNowPlaying().catch(() => null),
|
||||||
|
fetchListeners().catch(() => null),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ nowPlaying, listeners })}\n\n`));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial event immediately, then on an interval.
|
||||||
|
await send();
|
||||||
|
const interval = setInterval(() => void send(), 10_000);
|
||||||
|
// SSE comment as a keep-alive ping between data events.
|
||||||
|
const ping = setInterval(() => {
|
||||||
|
if (!closed) {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 25_000);
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
closed = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
clearInterval(ping);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.signal.addEventListener("abort", stop);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
|
"cache-control": "no-store, no-transform",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Public REST API — post a reply to a help-center ticket.
|
||||||
|
//
|
||||||
|
// Bearer-authed. POST inserts a reply ({ content }) authored by the current user
|
||||||
|
// into website_help_center_ticket_replies. The target ticket must exist and
|
||||||
|
// belong to the authed user. Fail-soft: never a 500.
|
||||||
|
|
||||||
|
import { apiError, apiJson } from "@/lib/api";
|
||||||
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// POST /api/tickets/:id/reply body: { content }
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const uid = await bearerUserId(req);
|
||||||
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
|
||||||
|
const ticketId = BigInt(id);
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { content?: unknown };
|
||||||
|
const content = String(body.content ?? "").trim().slice(0, 5000);
|
||||||
|
if (!content) return apiError("Content is required");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ownership check — only the ticket owner may reply.
|
||||||
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const reply = await prisma.websiteHelpCenterTicketReplies.create({
|
||||||
|
data: {
|
||||||
|
ticketId,
|
||||||
|
userId: uid,
|
||||||
|
content,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
select: { id: true, userId: true, content: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch the parent ticket so its updatedAt reflects the latest activity.
|
||||||
|
prisma.websiteHelpCenterTickets
|
||||||
|
.update({ where: { id: ticketId }, data: { updatedAt: now }, select: { id: true } })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return apiJson(
|
||||||
|
{
|
||||||
|
reply: {
|
||||||
|
id: reply.id,
|
||||||
|
userId: reply.userId,
|
||||||
|
content: reply.content,
|
||||||
|
createdAt: reply.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return apiError("Failed to post reply", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Public REST API — a single help-center ticket (with replies).
|
||||||
|
//
|
||||||
|
// Bearer-authed. GET returns one ticket that MUST belong to the authed user,
|
||||||
|
// together with its replies; reply author usernames are resolved in a single
|
||||||
|
// users lookup. Fail-soft: never a 500.
|
||||||
|
|
||||||
|
import { apiError, apiJson } from "@/lib/api";
|
||||||
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/tickets/:id
|
||||||
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const uid = await bearerUserId(req);
|
||||||
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
|
||||||
|
const ticketId = BigInt(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
categoryId: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
open: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ownership check — return 404 (not 403) so a foreign id is indistinguishable
|
||||||
|
// from a missing one.
|
||||||
|
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
|
||||||
|
|
||||||
|
const replies = await prisma.websiteHelpCenterTicketReplies.findMany({
|
||||||
|
where: { ticketId },
|
||||||
|
select: { id: true, userId: true, content: true, createdAt: true },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve author usernames in one query.
|
||||||
|
const authorIds = [...new Set(replies.map((r) => r.userId))];
|
||||||
|
const authors = authorIds.length
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
where: { id: { in: authorIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const nameById = new Map(authors.map((a) => [a.id, a.username]));
|
||||||
|
|
||||||
|
return apiJson({
|
||||||
|
ticket: {
|
||||||
|
id: ticket.id,
|
||||||
|
categoryId: ticket.categoryId,
|
||||||
|
title: ticket.title,
|
||||||
|
content: ticket.content,
|
||||||
|
open: ticket.open,
|
||||||
|
createdAt: ticket.createdAt,
|
||||||
|
replies: replies.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
userId: r.userId,
|
||||||
|
username: nameById.get(r.userId) ?? null,
|
||||||
|
content: r.content,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return apiError("Failed to load ticket", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Public REST API — help-center tickets (collection).
|
||||||
|
//
|
||||||
|
// Bearer-authed. GET lists the authed user's own tickets; POST opens a new one.
|
||||||
|
// Backed by website_help_center_tickets (WebsiteHelpCenterTickets). Fail-soft:
|
||||||
|
// DB errors return an apiError envelope, never a 500.
|
||||||
|
|
||||||
|
import { apiError, apiJson } from "@/lib/api";
|
||||||
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/tickets — the authed user's tickets (newest first).
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const uid = await bearerUserId(req);
|
||||||
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tickets = await prisma.websiteHelpCenterTickets.findMany({
|
||||||
|
where: { userId: uid },
|
||||||
|
select: { id: true, title: true, open: true, createdAt: true },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiJson({
|
||||||
|
tickets: tickets.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
open: t.open,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return apiError("Failed to load tickets", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tickets — open a new ticket ({ title, content, categoryId? }).
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const uid = await bearerUserId(req);
|
||||||
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
title?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
categoryId?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = String(body.title ?? "").trim().slice(0, 255);
|
||||||
|
const content = String(body.content ?? "").trim().slice(0, 5000);
|
||||||
|
if (!title) return apiError("Title is required");
|
||||||
|
if (!content) return apiError("Content is required");
|
||||||
|
|
||||||
|
// categoryId is an optional unsigned BigInt FK — accept a positive numeric
|
||||||
|
// value, otherwise leave it null.
|
||||||
|
let categoryId: bigint | null = null;
|
||||||
|
if (body.categoryId !== undefined && body.categoryId !== null && body.categoryId !== "") {
|
||||||
|
const raw = String(body.categoryId);
|
||||||
|
if (/^\d+$/.test(raw)) categoryId = BigInt(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const ticket = await prisma.websiteHelpCenterTickets.create({
|
||||||
|
data: {
|
||||||
|
userId: uid,
|
||||||
|
categoryId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
open: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, open: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiJson(
|
||||||
|
{
|
||||||
|
ticket: {
|
||||||
|
id: ticket.id,
|
||||||
|
title: ticket.title,
|
||||||
|
open: ticket.open,
|
||||||
|
createdAt: ticket.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return apiError("Failed to create ticket", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Public REST API — issue a personal access token for the SIGNED-IN user.
|
||||||
|
//
|
||||||
|
// POST /api/tokens — mints a new Sanctum-style personal_access_token bound to
|
||||||
|
// the NextAuth-authenticated user and returns the plaintext ONCE. The plaintext
|
||||||
|
// is never stored (only its sha256 hash lives in the DB) so it cannot be shown
|
||||||
|
// again. Requires a logged-in web session, not a Bearer token.
|
||||||
|
|
||||||
|
import { apiError, apiJson } from "@/lib/api";
|
||||||
|
import { issueToken } from "@/lib/api-auth";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
const id = session?.user?.id ? Number(session.user.id) : null;
|
||||||
|
if (!id || Number.isNaN(id)) {
|
||||||
|
return apiError("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { name?: unknown };
|
||||||
|
const rawName = typeof body.name === "string" ? body.name.trim() : "";
|
||||||
|
const name = rawName ? rawName.slice(0, 100) : "api";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await issueToken(id, name);
|
||||||
|
if (!token) {
|
||||||
|
return apiError("Could not issue token", 500);
|
||||||
|
}
|
||||||
|
// Plaintext token — shown only once, never recoverable afterwards.
|
||||||
|
return apiJson({ token });
|
||||||
|
} catch {
|
||||||
|
return apiError("Could not issue token", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { buyBadge } from "@/actions/draw-badge";
|
||||||
|
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
// Reads the live users + website_drawbadges tables and writes credits/badges on
|
||||||
|
// purchase — must never be statically rendered.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = { title: "Draw a Badge" };
|
||||||
|
|
||||||
|
const DEFAULT_PRICE = 50;
|
||||||
|
|
||||||
|
type DrawBadge = {
|
||||||
|
id: bigint;
|
||||||
|
badgeUrl: string;
|
||||||
|
badgeName: string;
|
||||||
|
badgeDesc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOUGHT_NOTE = (code: string) =>
|
||||||
|
`Badge "${code}" has been added to your inventory. Enjoy!`;
|
||||||
|
|
||||||
|
const ERROR_NOTE: Record<string, string> = {
|
||||||
|
invalid: "That badge is no longer available.",
|
||||||
|
credits: "You don't have enough credits to buy this badge.",
|
||||||
|
fail: "Something went wrong. Please try again.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DrawBadgePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
// Next 16: searchParams is a Promise.
|
||||||
|
searchParams: Promise<{ bought?: string; error?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
|
const { bought = "", error = "" } = await searchParams;
|
||||||
|
|
||||||
|
// Resolve the flat purchase price + the buyer's balance + the published
|
||||||
|
// badges, all fail-soft so the page still renders if the DB is unreachable.
|
||||||
|
let price = DEFAULT_PRICE;
|
||||||
|
let credits = 0;
|
||||||
|
let badges: DrawBadge[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await siteSettings.get("drawbadge.price", String(DEFAULT_PRICE));
|
||||||
|
const n = Number(raw);
|
||||||
|
if (Number.isFinite(n) && n >= 0) price = Math.floor(n);
|
||||||
|
} catch {
|
||||||
|
price = DEFAULT_PRICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buyer = await prisma.user.findUnique({
|
||||||
|
where: { id: Number(session.user.id) },
|
||||||
|
select: { credits: true },
|
||||||
|
});
|
||||||
|
credits = buyer?.credits ?? 0;
|
||||||
|
} catch {
|
||||||
|
credits = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
badges = await prisma.websiteDrawbadges.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
select: { id: true, badgeUrl: true, badgeName: true, badgeDesc: true },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
badges = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
<ContentCard
|
||||||
|
icon="🎨"
|
||||||
|
title="Draw a Badge"
|
||||||
|
subtitle="Buy a community-designed badge and wear it on your profile."
|
||||||
|
>
|
||||||
|
<div className="stat-grid">
|
||||||
|
<StatBlock value={credits.toLocaleString()} label="Your credits" icon="🪙" />
|
||||||
|
<StatBlock value={price.toLocaleString()} label="Price per badge" icon="🏷️" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bought ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
style={{
|
||||||
|
margin: "1rem 0 0",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-secondary-hover)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{BOUGHT_NOTE(bought)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
style={{ margin: "1rem 0 0", fontWeight: 700, color: "var(--color-danger)" }}
|
||||||
|
>
|
||||||
|
{ERROR_NOTE[error] ?? ERROR_NOTE.fail}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
<ContentCard
|
||||||
|
icon="🏅"
|
||||||
|
title="Available badges"
|
||||||
|
subtitle={
|
||||||
|
badges.length > 0 ? `${badges.length} badge(s) available` : undefined
|
||||||
|
}
|
||||||
|
padded={badges.length === 0}
|
||||||
|
>
|
||||||
|
{badges.length === 0 ? (
|
||||||
|
<EmptyState icon="🎨">
|
||||||
|
No badges have been published yet. Check back soon!
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||||
|
{badges.map((b) => {
|
||||||
|
const affordable = credits >= price;
|
||||||
|
return (
|
||||||
|
<article key={String(b.id)} className="card hover">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginBottom: "0.6rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
background: "#f8fafc",
|
||||||
|
border: "2px solid var(--border-color)",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.badgeUrl ? (
|
||||||
|
// Badge images are arbitrary external URLs stored by the
|
||||||
|
// draw-badge tool, so a plain <img> is correct here.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={b.badgeUrl}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
style={{ objectFit: "contain", display: "block" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>{b.badgeName}</h3>
|
||||||
|
<span className="currency">
|
||||||
|
<span className="coin credits">cr</span>
|
||||||
|
{price.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{b.badgeDesc ? (
|
||||||
|
<p style={{ margin: "0 0 0.85rem" }}>{b.badgeDesc}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form action={buyBadge}>
|
||||||
|
<input type="hidden" name="id" value={String(b.id)} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
disabled={!affordable}
|
||||||
|
aria-disabled={!affordable}
|
||||||
|
>
|
||||||
|
{affordable ? "Buy badge" : "Not enough credits"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -170,6 +170,21 @@ body {
|
|||||||
background: url("/assets/images/background-dark.jpg") no-repeat fixed right bottom;
|
background: url("/assets/images/background-dark.jpg") no-repeat fixed right bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-body {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.article-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.article-body p {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
}
|
||||||
|
.article-body a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.text-body {
|
.text-body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -32,14 +32,16 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|||||||
await enforceSiteAccess();
|
await enforceSiteAccess();
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
// "Dusk" / dark-by-default: the site starts dark unless the visitor has picked
|
||||||
|
// light. The saved choice always wins over the default.
|
||||||
|
const defaultDark = await siteSettings.getBool("default_dark", false);
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className={`app ${nunito.variable}`}>
|
<html lang={locale} className={`app ${nunito.variable}`}>
|
||||||
<head>
|
<head>
|
||||||
{/* Apply the saved theme before first paint to avoid a light→dark flash. */}
|
{/* Apply the saved/default theme before first paint to avoid a flash. */}
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__html: `try{var s=localStorage.getItem('theme');if(s==='dark'||(!s&&${defaultDark}))document.documentElement.classList.add('dark');}catch(e){}`,
|
||||||
"try{if(localStorage.getItem('theme')==='dark')document.documentElement.classList.add('dark');}catch(e){}",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the user's referral link to the clipboard. Mirrors AtomCMS's
|
||||||
|
* copyCode() helper on the /me page, but resolves the link to an absolute URL
|
||||||
|
* (the server only knows a relative path, so we prepend the current origin).
|
||||||
|
*/
|
||||||
|
export default function CopyReferralButton({ value }: { value: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
const absolute =
|
||||||
|
typeof window !== "undefined" && value.startsWith("/")
|
||||||
|
? `${window.location.origin}${value}`
|
||||||
|
: value;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(absolute);
|
||||||
|
} catch {
|
||||||
|
// Older browsers / insecure contexts: fall back to a hidden textarea.
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = absolute;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch {
|
||||||
|
/* give up silently */
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={copy}>
|
||||||
|
{copied ? "Copied!" : "Copy link"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import CopyReferralButton from "./CopyReferralButton";
|
||||||
|
import { ContentCard, EmptyState, OnlineBadge, StatBlock } from "@/components/public/ui";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { claimReferral } from "@/actions/referral";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = { title: "My dashboard" };
|
||||||
|
|
||||||
|
type SearchParams = Promise<{ claimed?: string; error?: string }>;
|
||||||
|
|
||||||
|
/** Inline-styled feedback banner using the theme's CSS variables. */
|
||||||
|
function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
|
||||||
|
const accent =
|
||||||
|
tone === "error"
|
||||||
|
? "var(--color-danger)"
|
||||||
|
: tone === "warning"
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "var(--color-primary)";
|
||||||
|
return {
|
||||||
|
margin: 0,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${accent}`,
|
||||||
|
color: "var(--color-text)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderLeft: `4px solid ${accent}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
not_enough: "You do not have enough referrals to claim your reward yet.",
|
||||||
|
no_referrals: "You haven't referred anyone yet — share your link to get started.",
|
||||||
|
bad_config:
|
||||||
|
"The referral reward is not configured correctly. Please contact a staff member.",
|
||||||
|
error: "Something went wrong while claiming your reward. Please try again.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function MePage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
if (!Number.isFinite(userId) || userId <= 0) redirect("/login");
|
||||||
|
|
||||||
|
const { claimed, error } = await searchParams;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
// --- Account + reward config -----------------------------------------
|
||||||
|
const [user, imagerBase, neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
look: true,
|
||||||
|
motto: true,
|
||||||
|
rank: true,
|
||||||
|
credits: true,
|
||||||
|
accountCreated: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
||||||
|
prisma.websiteSetting
|
||||||
|
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } })
|
||||||
|
.catch(() => null),
|
||||||
|
prisma.websiteSetting
|
||||||
|
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } })
|
||||||
|
.catch(() => null),
|
||||||
|
prisma.websiteSetting
|
||||||
|
.findFirst({
|
||||||
|
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } },
|
||||||
|
select: { value: true },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The session was already validated above; a missing row here is an
|
||||||
|
// exceptional state, so surface the error card rather than redirect from
|
||||||
|
// inside the try (a redirect() throw would be swallowed by the catch).
|
||||||
|
if (!user) throw new Error("user-not-found");
|
||||||
|
|
||||||
|
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||||
|
const rewardAmount = Number.parseInt(amountRaw?.value ?? "30", 10) || 0;
|
||||||
|
const rewardCurrency = (currencyRaw?.value ?? "diamonds").trim().toLowerCase();
|
||||||
|
|
||||||
|
// --- Referral tally ---------------------------------------------------
|
||||||
|
const referrals = await prisma.userReferrals
|
||||||
|
.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
select: { referralsTotal: true },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
const referralTotal = referrals ? Number(referrals.referralsTotal) : 0;
|
||||||
|
const canClaim = referralTotal >= needed;
|
||||||
|
const remaining = Math.max(0, needed - referralTotal);
|
||||||
|
|
||||||
|
// --- Online friends ---------------------------------------------------
|
||||||
|
// Friendships are bidirectional and stored once: the user can be either
|
||||||
|
// user_one_id or user_two_id; the OTHER column is the friend.
|
||||||
|
const friendships = await prisma.messengerFriendships
|
||||||
|
.findMany({
|
||||||
|
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
|
||||||
|
select: { userOneId: true, userTwoId: true },
|
||||||
|
})
|
||||||
|
.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 prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { id: { in: friendIds } },
|
||||||
|
select: { id: true, username: true, look: true, motto: true, online: true },
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const onlineFriends = friends.filter((f) => f.online === "1");
|
||||||
|
|
||||||
|
const registered = new Date(user.accountCreated * 1000).toISOString().slice(0, 10);
|
||||||
|
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
||||||
|
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{/* Feedback banners --------------------------------------------- */}
|
||||||
|
{claimed ? (
|
||||||
|
<div role="status" style={feedbackStyle("success")}>
|
||||||
|
Woah! You have successfully claimed your reward — keep up the good work!
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" style={feedbackStyle("error")}>
|
||||||
|
{ERROR_MESSAGES[error] ?? ERROR_MESSAGES.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Header card -------------------------------------------------- */}
|
||||||
|
<ContentCard
|
||||||
|
icon="🏠"
|
||||||
|
title={user.username}
|
||||||
|
subtitle={user.motto || "Welcome back!"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stat tiles --------------------------------------------------- */}
|
||||||
|
<div className="card-grid sm-2 lg-3">
|
||||||
|
<StatBlock icon="💰" value={user.credits.toLocaleString()} label="Credits" />
|
||||||
|
<StatBlock icon="🎖️" value={`Rank ${user.rank}`} label="Hotel rank" />
|
||||||
|
<StatBlock icon="📅" value={registered} label="Registered" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Online friends ----------------------------------------------- */}
|
||||||
|
<ContentCard
|
||||||
|
icon="🟢"
|
||||||
|
title="Online friends"
|
||||||
|
subtitle={
|
||||||
|
onlineFriends.length === 0
|
||||||
|
? "None of your friends are online right now"
|
||||||
|
: `${onlineFriends.length} of your ${friends.length} friends online`
|
||||||
|
}
|
||||||
|
action={<Link href="/friends">View all</Link>}
|
||||||
|
padded={onlineFriends.length === 0}
|
||||||
|
>
|
||||||
|
{onlineFriends.length === 0 ? (
|
||||||
|
<EmptyState icon="💤">
|
||||||
|
No friends online right now. Check back later!
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||||
|
{onlineFriends.map((friend) => {
|
||||||
|
const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
|
||||||
|
size: "s",
|
||||||
|
headOnly: true,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={friend.id}
|
||||||
|
className="card hover"
|
||||||
|
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
className="avatar"
|
||||||
|
src={avatar}
|
||||||
|
alt={`${friend.username} avatar`}
|
||||||
|
width={50}
|
||||||
|
height={50}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
||||||
|
<Link href={`/u/${friend.username}`}>{friend.username}</Link>
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
margin: "0 0 0.4rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{friend.motto || "No motto"}
|
||||||
|
</p>
|
||||||
|
<OnlineBadge online />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
|
||||||
|
{/* Referral card ------------------------------------------------ */}
|
||||||
|
<ContentCard
|
||||||
|
icon="🤝"
|
||||||
|
title={`Refer a friend (${referralTotal}/${needed})`}
|
||||||
|
subtitle="Refer new users and earn in-game rewards"
|
||||||
|
>
|
||||||
|
<div style={{ display: "grid", gap: "1rem" }}>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
{rewardAmount > 0
|
||||||
|
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
|
||||||
|
: `Invite ${needed} users through your referral link to claim a reward!`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div role="note" style={feedbackStyle("warning")}>
|
||||||
|
⚠️ Boosting referrals by making your own accounts will lead to
|
||||||
|
removal of all progress, currency, inventory and a potential ban.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="referral-link"
|
||||||
|
className="muted"
|
||||||
|
style={{ display: "block", marginBottom: "0.35rem", fontSize: "0.85rem" }}
|
||||||
|
>
|
||||||
|
Your referral link
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
id="referral-link"
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
defaultValue={referralLink}
|
||||||
|
className="input"
|
||||||
|
style={{ flex: "1 1 240px", minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
<CopyReferralButton value={referralLink} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canClaim ? (
|
||||||
|
<form action={claimReferral}>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ width: "100%" }}>
|
||||||
|
Claim your referral reward!
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="btn"
|
||||||
|
style={{ width: "100%", opacity: 0.6, cursor: "not-allowed" }}
|
||||||
|
>
|
||||||
|
{`Refer ${remaining} more ${remaining === 1 ? "user" : "users"} to unlock your reward`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContentCard>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
content = (
|
||||||
|
<ContentCard icon="⚠️" title="My dashboard">
|
||||||
|
<EmptyState icon="⚠️">
|
||||||
|
We couldn't load your dashboard right now. Please try again shortly.
|
||||||
|
</EmptyState>
|
||||||
|
</ContentCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main style={{ display: "grid", gap: "1.5rem" }}>{content}</main>;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
|
import { sanitize } from "@/lib/sanitize";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { postComment } from "@/actions/article-comments";
|
import { postComment } from "@/actions/article-comments";
|
||||||
import { toggleReaction } from "@/actions/article-reactions";
|
import { toggleReaction } from "@/actions/article-reactions";
|
||||||
@@ -123,7 +124,9 @@ export default async function ArticlePage({
|
|||||||
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div>
|
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
|
||||||
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised article body */}
|
||||||
|
<div className="article-body" dangerouslySetInnerHTML={{ __html: sanitize(article.fullStory) }} />
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Reactions ─────────────────────────────────────────── */}
|
{/* ── Reactions ─────────────────────────────────────────── */}
|
||||||
|
|||||||
+4
-3
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sanitize } from "@/lib/sanitize";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -93,9 +94,9 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
{boxes.map((b) => (
|
{boxes.map((b) => (
|
||||||
<ContentCard key={String(b.id)} icon={b.icon ?? "📌"} title={b.title}>
|
<ContentCard key={String(b.id)} icon={b.icon ?? "📌"} title={b.title}>
|
||||||
{/* Content is authored by staff in housekeeping (trusted HTML). */}
|
{/* Staff-authored HTML, sanitised server-side before injection. */}
|
||||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: staff-authored writeable box */}
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised writeable box */}
|
||||||
<div dangerouslySetInnerHTML={{ __html: b.content }} />
|
<div dangerouslySetInnerHTML={{ __html: sanitize(b.content) }} />
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
))}
|
))}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export async function Navigation() {
|
|||||||
<Link href="/badges" className="dropdown-item">
|
<Link href="/badges" className="dropdown-item">
|
||||||
{t("badges")}
|
{t("badges")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/draw-badge" className="dropdown-item">
|
||||||
|
{t("drawBadge")}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ export async function TopHeader() {
|
|||||||
className="absolute right-0 mt-1 min-w-[180px] rounded-md shadow-lg z-50 py-1"
|
className="absolute right-0 mt-1 min-w-[180px] rounded-md shadow-lg z-50 py-1"
|
||||||
style={{ backgroundColor: "var(--color-dropdown)" }}
|
style={{ backgroundColor: "var(--color-dropdown)" }}
|
||||||
>
|
>
|
||||||
|
<Link href="/me" className="dropdown-item">
|
||||||
|
My dashboard
|
||||||
|
</Link>
|
||||||
<Link href={`/u/${session.user.name}`} className="dropdown-item">
|
<Link href={`/u/${session.user.name}`} className="dropdown-item">
|
||||||
My profile
|
My profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer-token auth for the public REST API, backed by personal_access_tokens
|
||||||
|
* (the Laravel Sanctum table that already exists in the emulator DB). Tokens are
|
||||||
|
* stored as the sha256 of the plaintext; the client sends the plaintext (or the
|
||||||
|
* Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`.
|
||||||
|
*
|
||||||
|
* NOTE: the live amx_test table has NO expires_at column — never read/write it.
|
||||||
|
*/
|
||||||
|
const TOKENABLE_TYPE = "App\\Models\\User";
|
||||||
|
|
||||||
|
function hashToken(raw: string): string {
|
||||||
|
return createHash("sha256").update(raw).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the user id behind a Bearer token, or null. */
|
||||||
|
export async function bearerUserId(req: Request): Promise<number | null> {
|
||||||
|
const header = req.headers.get("authorization") ?? "";
|
||||||
|
const m = header.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!m) return null;
|
||||||
|
let raw = m[1].trim();
|
||||||
|
const pipe = raw.indexOf("|");
|
||||||
|
if (pipe >= 0) raw = raw.slice(pipe + 1); // Sanctum "{id}|{token}"
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await prisma.personalAccessTokens.findFirst({
|
||||||
|
where: { token: hashToken(raw) },
|
||||||
|
select: { id: true, tokenableId: true },
|
||||||
|
});
|
||||||
|
if (!row) return null;
|
||||||
|
// Best-effort last-used stamp (don't fail the request if it errors).
|
||||||
|
prisma.personalAccessTokens
|
||||||
|
.update({ where: { id: row.id }, data: { lastUsedAt: new Date() }, select: { id: true } })
|
||||||
|
.catch(() => {});
|
||||||
|
return Number(row.tokenableId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint a new token for a user. Returns the plaintext (shown once). */
|
||||||
|
export async function issueToken(userId: number, name = "api"): Promise<string | null> {
|
||||||
|
const plaintext = randomBytes(32).toString("hex");
|
||||||
|
try {
|
||||||
|
await prisma.personalAccessTokens.create({
|
||||||
|
data: {
|
||||||
|
tokenableId: BigInt(userId),
|
||||||
|
tokenableType: TOKENABLE_TYPE,
|
||||||
|
name: name.slice(0, 100),
|
||||||
|
token: hashToken(plaintext),
|
||||||
|
abilities: '["*"]',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return plaintext;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side HTML sanitiser for user/staff-authored rich content before it is
|
||||||
|
* injected via dangerouslySetInnerHTML — the AtomCMS HTMLPurifier equivalent.
|
||||||
|
* Allows a safe formatting subset (no <script>/<style>/<iframe>, no on* event
|
||||||
|
* handlers, no javascript: URLs); images/links are permitted with safe schemes.
|
||||||
|
*/
|
||||||
|
const OPTIONS: sanitizeHtml.IOptions = {
|
||||||
|
allowedTags: [
|
||||||
|
"a", "b", "i", "em", "strong", "u", "s", "p", "br", "hr", "span", "div",
|
||||||
|
"ul", "ol", "li", "blockquote", "code", "pre",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"img", "figure", "figcaption", "table", "thead", "tbody", "tr", "th", "td",
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ["href", "title", "target", "rel"],
|
||||||
|
img: ["src", "alt", "title", "width", "height"],
|
||||||
|
"*": ["style", "class"],
|
||||||
|
},
|
||||||
|
allowedSchemes: ["http", "https", "mailto"],
|
||||||
|
allowedSchemesByTag: { img: ["http", "https", "data"] },
|
||||||
|
// Drop any style declarations that aren't simple, safe properties.
|
||||||
|
allowedStyles: {
|
||||||
|
"*": {
|
||||||
|
color: [/.*/],
|
||||||
|
"background-color": [/.*/],
|
||||||
|
"text-align": [/^left$|^right$|^center$|^justify$/],
|
||||||
|
"font-weight": [/.*/],
|
||||||
|
"font-style": [/.*/],
|
||||||
|
"text-decoration": [/.*/],
|
||||||
|
"font-size": [/.*/],
|
||||||
|
margin: [/.*/],
|
||||||
|
padding: [/.*/],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transformTags: {
|
||||||
|
a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer nofollow" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sanitize(html: string | null | undefined): string {
|
||||||
|
if (!html) return "";
|
||||||
|
return sanitizeHtml(html, OPTIONS);
|
||||||
|
}
|
||||||
@@ -226,4 +226,18 @@ export const PRESETS: Record<string, Record<string, string>> = {
|
|||||||
button_text_color: "#ffffff",
|
button_text_color: "#ffffff",
|
||||||
border_color: "#16a34a",
|
border_color: "#16a34a",
|
||||||
},
|
},
|
||||||
|
Dusk: {
|
||||||
|
color_primary: "#e8a33d",
|
||||||
|
color_background: "#1b2230",
|
||||||
|
color_surface: "#232c3d",
|
||||||
|
color_dropdown: "#2b3548",
|
||||||
|
color_navbar: "#232c3d",
|
||||||
|
color_navbar_text: "#dfe5ef",
|
||||||
|
color_text: "#dfe5ef",
|
||||||
|
color_text_muted: "#94a0b5",
|
||||||
|
color_accent: "#5eb0c9",
|
||||||
|
button_primary_color: "#e8a33d",
|
||||||
|
button_text_color: "#1b2230",
|
||||||
|
border_color: "#e8a33d",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
"radio": "Radio",
|
"radio": "Radio",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
"messages": "Messages",
|
"messages": "Messages",
|
||||||
"admin": "Admin"
|
"admin": "Admin",
|
||||||
|
"drawBadge": "Draw badge"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"radio": "Radio",
|
"radio": "Radio",
|
||||||
"friends": "Amici",
|
"friends": "Amici",
|
||||||
"messages": "Messaggi",
|
"messages": "Messaggi",
|
||||||
"admin": "Admin"
|
"admin": "Admin",
|
||||||
|
"drawBadge": "Crea distintivo"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} online su {hotel}",
|
"online": "{count} online su {hotel}",
|
||||||
|
|||||||
Reference in new issue
Block a user