From f7b3845131c87c5af601da968d26df162d333f81 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 29 Jun 2026 18:15:01 +0200 Subject: [PATCH] Close the web-feasible 100% gaps: REST write/token API, tickets, draw-badge, /me, sanitisation, dusk, radio SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 + pnpm-lock.yaml | 114 +++++++ src/actions/draw-badge.ts | 134 ++++++++ src/actions/referral.ts | 153 +++++++++ src/app/api/articles/[slug]/comment/route.ts | 58 ++++ src/app/api/me/tokens/route.ts | 61 ++++ src/app/api/radio/current-dj/route.ts | 34 ++ src/app/api/radio/embed-config/route.ts | 33 ++ src/app/api/radio/points/leaderboard/route.ts | 48 +++ src/app/api/radio/points/route.ts | 24 ++ src/app/api/radio/shouts/route.ts | 40 ++- src/app/api/radio/stream/route.ts | 66 ++++ src/app/api/tickets/[id]/reply/route.ts | 65 ++++ src/app/api/tickets/[id]/route.ts | 76 +++++ src/app/api/tickets/route.ts | 91 ++++++ src/app/api/tokens/route.ts | 35 ++ src/app/draw-badge/page.tsx | 197 ++++++++++++ src/app/globals.css | 15 + src/app/layout.tsx | 8 +- src/app/me/CopyReferralButton.tsx | 44 +++ src/app/me/page.tsx | 299 ++++++++++++++++++ src/app/news/[slug]/page.tsx | 5 +- src/app/page.tsx | 7 +- src/components/navigation.tsx | 3 + src/components/top-header.tsx | 3 + src/lib/api-auth.ts | 64 ++++ src/lib/sanitize.ts | 45 +++ src/lib/theme-presets.ts | 14 + src/messages/en.json | 3 +- src/messages/it.json | 3 +- 30 files changed, 1734 insertions(+), 10 deletions(-) create mode 100644 src/actions/draw-badge.ts create mode 100644 src/actions/referral.ts create mode 100644 src/app/api/articles/[slug]/comment/route.ts create mode 100644 src/app/api/me/tokens/route.ts create mode 100644 src/app/api/radio/current-dj/route.ts create mode 100644 src/app/api/radio/embed-config/route.ts create mode 100644 src/app/api/radio/points/leaderboard/route.ts create mode 100644 src/app/api/radio/points/route.ts create mode 100644 src/app/api/radio/stream/route.ts create mode 100644 src/app/api/tickets/[id]/reply/route.ts create mode 100644 src/app/api/tickets/[id]/route.ts create mode 100644 src/app/api/tickets/route.ts create mode 100644 src/app/api/tokens/route.ts create mode 100644 src/app/draw-badge/page.tsx create mode 100644 src/app/me/CopyReferralButton.tsx create mode 100644 src/app/me/page.tsx create mode 100644 src/lib/api-auth.ts create mode 100644 src/lib/sanitize.ts diff --git a/package.json b/package.json index 57cd36c0..aacaffce 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "otplib": "^12.0.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "sanitize-html": "^2.17.5", "zod": "^3.24.0" }, "devDependencies": { @@ -40,6 +41,7 @@ "@types/nodemailer": "^6.4.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "@types/sanitize-html": "^2.16.1", "dotenv": "^16.4.0", "postcss": "^8.5.15", "prisma": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eca8b04..6e9c8556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.7(react@19.2.7) + sanitize-html: + specifier: ^2.17.5 + version: 2.17.5 zod: specifier: ^3.24.0 version: 3.25.76 @@ -69,6 +72,9 @@ importers: '@types/react-dom': specifier: ^19.2.0 version: 19.2.3(@types/react@19.2.17) + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 dotenv: specifier: ^16.4.0 version: 16.6.1 @@ -1593,6 +1599,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -1701,6 +1710,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1718,6 +1730,10 @@ packages: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -1732,6 +1748,19 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1847,6 +1876,14 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1879,6 +1916,10 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1942,6 +1983,9 @@ packages: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-status-codes@2.3.0: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} @@ -1967,6 +2011,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -1987,6 +2035,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -2177,6 +2228,9 @@ packages: otplib@12.0.1: resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2288,6 +2342,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.17.5: + resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3476,6 +3533,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -3596,6 +3657,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3604,6 +3667,8 @@ snapshots: deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} + defu@6.1.7: {} denque@2.1.0: {} @@ -3612,6 +3677,24 @@ snapshots: detect-libc@2.1.2: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.6.1: {} dotenv@17.4.2: {} @@ -3642,6 +3725,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@4.5.0: {} + + entities@7.0.1: {} + env-paths@3.0.0: {} es-module-lexer@1.7.0: {} @@ -3759,6 +3846,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-string-regexp@4.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -3816,6 +3905,13 @@ snapshots: hono@4.12.27: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-status-codes@2.3.0: {} iconv-lite@0.6.3: @@ -3841,6 +3937,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-plain-object@5.0.0: {} + is-property@1.0.2: {} isexe@2.0.0: {} @@ -3853,6 +3951,10 @@ snapshots: json-schema-traverse@1.0.0: {} + launder@1.7.1: + dependencies: + dayjs: 1.11.21 + lightningcss-android-arm64@1.32.0: optional: true @@ -4023,6 +4125,8 @@ snapshots: '@otplib/preset-default': 12.0.1 '@otplib/preset-v11': 12.0.1 + parse-srcset@1.0.2: {} + path-key@3.1.1: {} pathe@1.1.2: {} @@ -4150,6 +4254,16 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.17.5: + 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 + scheduler@0.27.0: {} semver@7.8.5: {} diff --git a/src/actions/draw-badge.ts b/src/actions/draw-badge.ts new file mode 100644 index 00000000..37d6914d --- /dev/null +++ b/src/actions/draw-badge.ts @@ -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 { + 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 { + 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}`); +} diff --git a/src/actions/referral.ts b/src/actions/referral.ts new file mode 100644 index 00000000..989386a9 --- /dev/null +++ b/src/actions/referral.ts @@ -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([ + "credits", + "duckets", + "diamonds", + "points", +]); + +export async function claimReferral(_formData: FormData): Promise { + 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}`); +} diff --git a/src/app/api/articles/[slug]/comment/route.ts b/src/app/api/articles/[slug]/comment/route.ts new file mode 100644 index 00000000..2c355e21 --- /dev/null +++ b/src/app/api/articles/[slug]/comment/route.ts @@ -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); + } +} diff --git a/src/app/api/me/tokens/route.ts b/src/app/api/me/tokens/route.ts new file mode 100644 index 00000000..1cf70a0e --- /dev/null +++ b/src/app/api/me/tokens/route.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/radio/current-dj/route.ts b/src/app/api/radio/current-dj/route.ts new file mode 100644 index 00000000..a971704e --- /dev/null +++ b/src/app/api/radio/current-dj/route.ts @@ -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 }); + } +} diff --git a/src/app/api/radio/embed-config/route.ts b/src/app/api/radio/embed-config/route.ts new file mode 100644 index 00000000..dbb30428 --- /dev/null +++ b/src/app/api/radio/embed-config/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/radio/points/leaderboard/route.ts b/src/app/api/radio/points/leaderboard/route.ts new file mode 100644 index 00000000..a246fe1c --- /dev/null +++ b/src/app/api/radio/points/leaderboard/route.ts @@ -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 }); + } +} diff --git a/src/app/api/radio/points/route.ts b/src/app/api/radio/points/route.ts new file mode 100644 index 00000000..7b5be54e --- /dev/null +++ b/src/app/api/radio/points/route.ts @@ -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 }); + } +} diff --git a/src/app/api/radio/shouts/route.ts b/src/app/api/radio/shouts/route.ts index 4c345237..4d1fd5ad 100644 --- a/src/app/api/radio/shouts/route.ts +++ b/src/app/api/radio/shouts/route.ts @@ -1,11 +1,15 @@ -import { apiJson } from "@/lib/api"; +import { apiJson, apiError } from "@/lib/api"; import { prisma } from "@/lib/prisma"; +import { bearerUserId } from "@/lib/api-auth"; // 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 // desc, then joined to users by user_id). 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) { try { const shouts = await prisma.radioShouts.findMany({ @@ -42,3 +46,37 @@ export async function GET(_req: Request) { 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); + } +} diff --git a/src/app/api/radio/stream/route.ts b/src/app/api/radio/stream/route.ts new file mode 100644 index 00000000..02cf3223 --- /dev/null +++ b/src/app/api/radio/stream/route.ts @@ -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({ + 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": "*", + }, + }); +} diff --git a/src/app/api/tickets/[id]/reply/route.ts b/src/app/api/tickets/[id]/reply/route.ts new file mode 100644 index 00000000..4d8cf24d --- /dev/null +++ b/src/app/api/tickets/[id]/reply/route.ts @@ -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); + } +} diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts new file mode 100644 index 00000000..f09ce6d4 --- /dev/null +++ b/src/app/api/tickets/[id]/route.ts @@ -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); + } +} diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts new file mode 100644 index 00000000..8a9516ca --- /dev/null +++ b/src/app/api/tickets/route.ts @@ -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); + } +} diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts new file mode 100644 index 00000000..af735edc --- /dev/null +++ b/src/app/api/tokens/route.ts @@ -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); + } +} diff --git a/src/app/draw-badge/page.tsx b/src/app/draw-badge/page.tsx new file mode 100644 index 00000000..37ce839d --- /dev/null +++ b/src/app/draw-badge/page.tsx @@ -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 = { + 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 ( +
+ +
+ + +
+ + {bought ? ( +

+ {BOUGHT_NOTE(bought)} +

+ ) : null} + {error ? ( +

+ {ERROR_NOTE[error] ?? ERROR_NOTE.fail} +

+ ) : null} +
+ + 0 ? `${badges.length} badge(s) available` : undefined + } + padded={badges.length === 0} + > + {badges.length === 0 ? ( + + No badges have been published yet. Check back soon! + + ) : ( +
+ {badges.map((b) => { + const affordable = credits >= price; + return ( +
+
+
+ {b.badgeUrl ? ( + // Badge images are arbitrary external URLs stored by the + // draw-badge tool, so a plain is correct here. + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+
+

{b.badgeName}

+ + cr + {price.toLocaleString()} + +
+
+ + {b.badgeDesc ? ( +

{b.badgeDesc}

+ ) : null} + +
+ + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index d4d31bf0..9193595e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -170,6 +170,21 @@ body { 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 { color: var(--color-text); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3b517ed1..2d61204f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -32,14 +32,16 @@ export default async function RootLayout({ children }: { children: ReactNode }) await enforceSiteAccess(); const locale = await getLocale(); 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 ( - {/* 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. */}