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
@@ -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 { 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);
|
||||
}
|
||||
}
|
||||
@@ -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": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user