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

+64
View File
@@ -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;
}
}