From 33b3e6f3c19ea386f0b03b14846fbdc0a2305f13 Mon Sep 17 00:00:00 2001 From: pleb Date: Mon, 3 Nov 2025 16:41:04 -0800 Subject: [PATCH] Add guestbook --- src/lib/components/Guestbook.svelte | 544 ++++++++++++++++++++++++++++ src/lib/server/guestbookStore.ts | 130 +++++++ src/routes/+page.svelte | 46 ++- src/routes/api/guestbook/+server.ts | 221 +++++++++++ 4 files changed, 925 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/Guestbook.svelte create mode 100644 src/lib/server/guestbookStore.ts create mode 100644 src/routes/api/guestbook/+server.ts diff --git a/src/lib/components/Guestbook.svelte b/src/lib/components/Guestbook.svelte new file mode 100644 index 0000000..ce858d2 --- /dev/null +++ b/src/lib/components/Guestbook.svelte @@ -0,0 +1,544 @@ + + +
+
+

Guestbook

+
+ + {#if viewer} +
+ + {#if newMessageError} +
{newMessageError}
+ {/if} +
+ +
+
+ {:else} + + {/if} + + {#if loading} +
Loading comments…
+ {:else if error} +
{error}
+ {:else if comments.length === 0} +
No comments yet. Be the first to say hello!
+ {:else} + + {/if} + +
+ + {#if pagination.total > 0} + Showing {showingStart}–{showingEnd} of {pagination.total} + {:else} + No comments yet + {/if} + +
+ + +
+
+
+ + + diff --git a/src/lib/server/guestbookStore.ts b/src/lib/server/guestbookStore.ts new file mode 100644 index 0000000..0d9bdfc --- /dev/null +++ b/src/lib/server/guestbookStore.ts @@ -0,0 +1,130 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export type GuestbookAuthor = { + beatleaderId: string; + name: string | null; + avatar: string | null; + country: string | null; + rank: number | null; + techPp: number | null; + accPp: number | null; + passPp: number | null; +}; + +export type GuestbookComment = { + id: string; + message: string; + author: GuestbookAuthor; + createdAt: number; + updatedAt: number | null; +}; + +type GuestbookFileData = { + comments: GuestbookComment[]; +}; + +const DATA_DIR = '.data'; +const GUESTBOOK_FILE = 'guestbook.json'; + +const EMPTY_DATA: GuestbookFileData = { comments: [] }; + +const CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F]/g; +const HTML_TAG_REGEX = /<[^>]*>/g; +const JAVASCRIPT_PROTOCOL_REGEX = /javascript:/gi; + +function sanitizeGuestbookMessage(value: string): string { + if (!value) return ''; + const withoutTags = value.replace(HTML_TAG_REGEX, ''); + const withoutControl = withoutTags.replace(CONTROL_CHARS_REGEX, ' '); + const withoutJsProtocol = withoutControl.replace(JAVASCRIPT_PROTOCOL_REGEX, ''); + return withoutJsProtocol.trim(); +} + +function ensureDataDir(): void { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function resolveGuestbookPath(): string { + return path.join(process.cwd(), DATA_DIR, GUESTBOOK_FILE); +} + +function loadGuestbookFile(): GuestbookFileData { + try { + const filePath = resolveGuestbookPath(); + if (!fs.existsSync(filePath)) { + return { ...EMPTY_DATA, comments: [] }; + } + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return { ...EMPTY_DATA, comments: [] }; + } + const comments = Array.isArray((parsed as GuestbookFileData).comments) + ? ((parsed as GuestbookFileData).comments as GuestbookComment[]) + : []; + return { comments }; + } catch (error) { + console.error('Failed to read guestbook store', error); + return { ...EMPTY_DATA, comments: [] }; + } +} + +function persistGuestbookFile(data: GuestbookFileData): void { + try { + ensureDataDir(); + fs.writeFileSync(resolveGuestbookPath(), JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + console.error('Failed to persist guestbook store', error); + } +} + +export function listGuestbookComments(): GuestbookComment[] { + const data = loadGuestbookFile(); + return [...data.comments] + .map((comment) => ({ ...comment, message: sanitizeGuestbookMessage(comment.message) })) + .sort((a, b) => b.createdAt - a.createdAt); +} + +export function addGuestbookComment(input: { + message: string; + author: GuestbookAuthor; +}): GuestbookComment { + const data = loadGuestbookFile(); + + const sanitizedMessage = sanitizeGuestbookMessage(input.message); + + const comment: GuestbookComment = { + id: randomUUID(), + message: sanitizedMessage, + author: input.author, + createdAt: Date.now(), + updatedAt: null + }; + + data.comments.push(comment); + persistGuestbookFile(data); + return comment; +} + +export function getGuestbookComments(): GuestbookComment[] { + return listGuestbookComments(); +} + +export function deleteGuestbookComment(id: string, requesterBeatleaderId: string): boolean { + if (!id) return false; + const data = loadGuestbookFile(); + const index = data.comments.findIndex( + (comment) => comment.id === id && comment.author.beatleaderId === requesterBeatleaderId + ); + if (index === -1) return false; + + data.comments.splice(index, 1); + persistGuestbookFile(data); + return true; +} + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7f672ac..d250ef3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,3 +1,7 @@ + +

@@ -21,27 +25,37 @@
Player History Comparison
A vs B: songs A played that B has not
- + +
Tool
+
Playlist Discovery
+
Experimental UX for browsing new playlists on BeatSaver
+

-
-
-

Latest Guides

-
-
- -
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 }); +}; + +