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:
Simo committed 2026-06-29 18:15:01 +02:00
1 parent 8cedf5614e
commit f7b3845131
30 files changed
+1734 -10

No files matched your search

@@ -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);
}
}
+61
View File
@@ -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);
}
}
+34
View File
@@ -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 });
}
}
+33
View File
@@ -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 });
}
}
+24
View File
@@ -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 });
}
}
+39 -1
View File
@@ -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);
}
}
+66
View File
@@ -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": "*",
},
});
}
+65
View File
@@ -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);
}
}
+76
View File
@@ -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);
}
}
+91
View File
@@ -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);
}
}
+35
View File
@@ -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);
}
}