-
- Guide
- Finding New Songs (BeatLeader)
- Month-by-month search using unranked stars, tech rating, and friend filters.
-
-
- Guide
- BeatLeader Authentication
- Connect BeatLeader and enable unranked stars in tools.
-
+
diff --git a/src/routes/api/guestbook/+server.ts b/src/routes/api/guestbook/+server.ts
new file mode 100644
index 0000000..1681dd7
--- /dev/null
+++ b/src/routes/api/guestbook/+server.ts
@@ -0,0 +1,221 @@
+import type { RequestHandler } from '@sveltejs/kit';
+import {
+ addGuestbookComment,
+ deleteGuestbookComment,
+ getGuestbookComments,
+ type GuestbookAuthor
+} from '$lib/server/guestbookStore';
+import { getSession } from '$lib/server/sessionStore';
+
+const MAX_COMMENT_LENGTH = 2000;
+const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/';
+const DEFAULT_PAGE_SIZE = 5;
+const MAX_PAGE_SIZE = 50;
+
+type GuestbookViewer = {
+ beatleaderId: string;
+ name: string | null;
+ avatar: string | null;
+};
+
+type GuestbookResponse = {
+ viewer: GuestbookViewer | null;
+ comments: ReturnType
;
+ pagination: {
+ total: number;
+ offset: number;
+ limit: number;
+ hasPrev: boolean;
+ hasNext: boolean;
+ };
+};
+
+function mapSessionToViewer(session: ReturnType): GuestbookViewer | null {
+ if (!session) return null;
+ return {
+ beatleaderId: session.beatleaderId,
+ name: session.name ?? null,
+ avatar: session.avatar ?? null
+ };
+}
+
+async function buildAuthor(session: NonNullable>, fetchFn: typeof fetch): Promise {
+ const base: GuestbookAuthor = {
+ beatleaderId: session.beatleaderId,
+ name: session.name ?? null,
+ avatar: session.avatar ?? null,
+ country: null,
+ rank: null,
+ techPp: null,
+ accPp: null,
+ passPp: null
+ };
+
+ try {
+ const res = await fetchFn(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
+ if (!res.ok) return base;
+ const data = (await res.json()) as Record;
+ return {
+ beatleaderId: session.beatleaderId,
+ name: typeof data.name === 'string' ? (data.name as string) : base.name,
+ avatar: typeof data.avatar === 'string' ? (data.avatar as string) : base.avatar,
+ country: typeof data.country === 'string' ? (data.country as string) : base.country,
+ rank: typeof data.rank === 'number' ? (data.rank as number) : base.rank,
+ techPp: typeof data.techPp === 'number' ? (data.techPp as number) : base.techPp,
+ accPp: typeof data.accPp === 'number' ? (data.accPp as number) : base.accPp,
+ passPp: typeof data.passPp === 'number' ? (data.passPp as number) : base.passPp
+ };
+ } catch (error) {
+ console.error('Failed to load BeatLeader profile for guestbook author', error);
+ return base;
+ }
+}
+
+function sanitizeMessage(value: unknown): { ok: true; message: string } | { ok: false; error: string } {
+ if (typeof value !== 'string') {
+ return { ok: false, error: 'Message must be a string' };
+ }
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return { ok: false, error: 'Message cannot be empty' };
+ }
+ if (trimmed.length > MAX_COMMENT_LENGTH) {
+ return { ok: false, error: `Message exceeds ${MAX_COMMENT_LENGTH} characters` };
+ }
+ return { ok: true, message: trimmed };
+}
+
+function parseLimit(value: string | null): number {
+ if (!value) return DEFAULT_PAGE_SIZE;
+ const parsed = Math.floor(Number(value));
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PAGE_SIZE;
+ return Math.min(MAX_PAGE_SIZE, parsed);
+}
+
+function parseOffset(value: string | null): number {
+ if (!value) return 0;
+ const parsed = Math.floor(Number(value));
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
+ return parsed;
+}
+
+function clampOffset(offset: number, total: number): number {
+ if (total <= 0) return 0;
+ const safeOffset = Math.max(0, Math.floor(offset));
+ const maxOffset = Math.max(0, total - 1);
+ return Math.min(safeOffset, maxOffset);
+}
+
+function buildGuestbookPayload(
+ session: ReturnType,
+ limit: number,
+ offset: number
+): GuestbookResponse {
+ const allComments = getGuestbookComments();
+ const total = allComments.length;
+ const safeLimit = Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(limit)));
+ const safeOffset = clampOffset(offset, total);
+ const pageComments = allComments.slice(safeOffset, safeOffset + safeLimit);
+
+ return {
+ viewer: mapSessionToViewer(session),
+ comments: pageComments,
+ pagination: {
+ total,
+ offset: safeOffset,
+ limit: safeLimit,
+ hasPrev: safeOffset > 0,
+ hasNext: safeOffset + safeLimit < total
+ }
+ };
+}
+
+function jsonResponse(payload: GuestbookResponse, init?: ResponseInit): Response {
+ return new Response(JSON.stringify(payload), {
+ headers: { 'content-type': 'application/json' },
+ ...init
+ });
+}
+
+export const GET: RequestHandler = ({ cookies, url }) => {
+ const session = getSession(cookies);
+ const limit = parseLimit(url.searchParams.get('limit'));
+ const offset = parseOffset(url.searchParams.get('offset'));
+
+ return jsonResponse(buildGuestbookPayload(session, limit, offset));
+};
+
+export const POST: RequestHandler = async ({ request, cookies, fetch }) => {
+ const session = getSession(cookies);
+ if (!session) {
+ return new Response(
+ JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }),
+ { status: 401, headers: { 'content-type': 'application/json' } }
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json' }
+ });
+ }
+
+ const sanitizedMessage = sanitizeMessage((body as Record)?.message);
+ if (!sanitizedMessage.ok) {
+ return new Response(JSON.stringify({ error: sanitizedMessage.error }), {
+ status: 400,
+ headers: { 'content-type': 'application/json' }
+ });
+ }
+
+ const { message } = sanitizedMessage;
+
+ try {
+ const author = await buildAuthor(session, fetch);
+ addGuestbookComment({ message, author });
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : 'Failed to save comment';
+ return new Response(JSON.stringify({ error: msg }), {
+ status: 400,
+ headers: { 'content-type': 'application/json' }
+ });
+ }
+
+ return jsonResponse(buildGuestbookPayload(session, DEFAULT_PAGE_SIZE, 0), { status: 201 });
+};
+
+export const DELETE: RequestHandler = ({ url, cookies }) => {
+ const session = getSession(cookies);
+ if (!session) {
+ return new Response(
+ JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }),
+ { status: 401, headers: { 'content-type': 'application/json' } }
+ );
+ }
+
+ const id = url.searchParams.get('id') || null;
+ if (!id) {
+ return new Response(JSON.stringify({ error: 'Missing comment id' }), {
+ status: 400,
+ headers: { 'content-type': 'application/json' }
+ });
+ }
+
+ const success = deleteGuestbookComment(id, session.beatleaderId);
+ if (!success) {
+ return new Response(JSON.stringify({ error: 'Comment not found or not owned by you' }), {
+ status: 404,
+ headers: { 'content-type': 'application/json' }
+ });
+ }
+
+ const limit = parseLimit(url.searchParams.get('limit'));
+ const offset = parseOffset(url.searchParams.get('offset'));
+ return jsonResponse(buildGuestbookPayload(session, limit, offset), { status: 200 });
+};
+
+