diff options
Diffstat (limited to 'server/index.ts')
| -rw-r--r-- | server/index.ts | 307 |
1 files changed, 270 insertions, 37 deletions
diff --git a/server/index.ts b/server/index.ts index c9cc6b4..3737105 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,9 +1,10 @@ -import express from 'express'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import path from 'path'; -import { songs } from './data/songs'; -import cors from 'cors'; -import dotenv from 'dotenv'; +import express from "express"; +import { createHmac, timingSafeEqual } from "crypto"; +import { songs } from "./data/songs"; +import cors from "cors"; +import dotenv from "dotenv"; +import rateLimit from "express-rate-limit"; + dotenv.config(); const app = express(); @@ -11,13 +12,27 @@ app.use(cors()); app.use(express.json()); const SERVER_PORT = process.env.SERVER_PORT || 3001; -const SALT = process.env.VITE_HEARDLE_SALT ?? 'changeme'; -const DAILY_SONGS_FILE = path.resolve( - process.env.DAILY_SONGS_FILE ?? 'server/data/daily-songs.json', -); +const SALT = process.env.VITE_HEARDLE_SALT ?? "changeme"; +const SIGNING_SECRET = process.env.HEARDLE_SIGNING_SECRET ?? SALT; type Song = (typeof songs)[number]; -type DailySongs = Record<string, Song>; + +type GuessResult = "Correct" | "PartiallyCorrect" | "Incorrect" | "Skipped"; + +type GuessEntry = { + song?: { + artist: string; + name: string; + }; + state: GuessResult; +}; + +type SignedState = { + date: string; + currentTry: number; + didGuess: boolean; + guesses: GuessEntry[]; +}; function getObfuscationKey(date = getUtcDate()): Buffer { return Buffer.from(SALT + date); @@ -34,6 +49,7 @@ function xorBuffer(data: Buffer, key: Buffer): Buffer { function getUtcDate(): string { return new Date().toISOString().slice(0, 10); } + function hashString(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -42,60 +58,277 @@ function hashString(str: string): number { return hash; } -function readDailySongs(): DailySongs { - if (!existsSync(DAILY_SONGS_FILE)) { - return {}; +function getDailySong(date: string): Song { + const seed = hashString(date); + const index = seed % songs.length; + return songs[index]; +} + +function signValue(prefix: string, value: string): string { + return createHmac("sha256", SIGNING_SECRET) + .update(`${prefix}:${value}`) + .digest("hex"); +} + +function safeEqualHex(a: string, b: string): boolean { + try { + const left = Buffer.from(a, "hex"); + const right = Buffer.from(b, "hex"); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); + } catch { + return false; } +} - return JSON.parse(readFileSync(DAILY_SONGS_FILE, 'utf8')) as DailySongs; +function buildSessionToken(date: string): string { + const payload = Buffer.from(JSON.stringify({ date }), "utf8").toString("base64url"); + const sig = signValue("session", payload); + return `${payload}.${sig}`; } -function writeDailySongs(dailySongs: DailySongs): void { - mkdirSync(path.dirname(DAILY_SONGS_FILE), { recursive: true }); - writeFileSync(DAILY_SONGS_FILE, JSON.stringify(dailySongs, null, 2)); +function verifySessionToken(token: unknown, expectedDate: string): boolean { + if (typeof token !== "string") return false; + const [payload, sig] = token.split("."); + if (!payload || !sig) return false; + + const expectedSig = signValue("session", payload); + if (!safeEqualHex(sig, expectedSig)) return false; + + try { + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + date?: unknown; + }; + + return typeof decoded.date === "string" && decoded.date === expectedDate; + } catch { + return false; + } } -function getDailySong(date: string): Song { - const dailySongs = readDailySongs(); - const savedSong = dailySongs[date]; +function normalizeGuessEntry(entry: unknown): GuessEntry | null { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; - if (savedSong) { - return savedSong; + const maybeState = (entry as { state?: unknown }).state; + if ( + maybeState !== "Correct" && + maybeState !== "PartiallyCorrect" && + maybeState !== "Incorrect" && + maybeState !== "Skipped" + ) { + return null; } - const seed = hashString(date); - const index = seed % songs.length; - const song = songs[index]; - dailySongs[date] = song; - writeDailySongs(dailySongs); - return song; + const maybeSong = (entry as { song?: unknown }).song; + + if (maybeState === "Skipped") { + return { state: "Skipped" }; + } + + if (!maybeSong || typeof maybeSong !== "object" || Array.isArray(maybeSong)) { + return null; + } + + const artist = (maybeSong as { artist?: unknown }).artist; + const name = (maybeSong as { name?: unknown }).name; + + if (typeof artist !== "string" || typeof name !== "string") { + return null; + } + + return { + song: { artist, name }, + state: maybeState, + }; } -app.get('/today', (_req, res) => { +function normalizeSignedState(input: unknown): SignedState | null { + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + + const date = (input as { date?: unknown }).date; + const currentTryRaw = (input as { currentTry?: unknown }).currentTry; + const didGuessRaw = (input as { didGuess?: unknown }).didGuess; + const guessesRaw = (input as { guesses?: unknown }).guesses; + + if (typeof date !== "string") return null; + if (!Array.isArray(guessesRaw)) return null; + if (typeof currentTryRaw !== "number" || !Number.isFinite(currentTryRaw)) return null; + + const currentTry = Math.floor(currentTryRaw); + if (currentTry < 0 || currentTry > 6) return null; + + const guesses: GuessEntry[] = []; + for (const entry of guessesRaw) { + const normalized = normalizeGuessEntry(entry); + if (!normalized) return null; + guesses.push(normalized); + } + + if (guesses.length > 6) return null; + if (guesses.length !== currentTry) return null; + + const didGuess = !!didGuessRaw; + if (didGuess && !guesses.some((guess) => guess.state === "Correct")) { + return null; + } + + return { + date, + currentTry, + didGuess, + guesses, + }; +} + +function canonicalState(state: SignedState): string { + return JSON.stringify({ + date: state.date, + currentTry: state.currentTry, + didGuess: state.didGuess, + guesses: state.guesses.map((guess) => ({ + state: guess.state, + song: guess.song + ? { + artist: guess.song.artist, + name: guess.song.name, + } + : null, + })), + }); +} + +function signState(state: SignedState): string { + return signValue("state", canonicalState(state)); +} + +function verifyStateSignature(state: SignedState, sig: unknown): boolean { + if (typeof sig !== "string") return false; + const expectedSig = signState(state); + return safeEqualHex(sig, expectedSig); +} + +const guessLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many requests. Please slow down." }, +}); + +app.get("/today", (_req, res) => { const date = getUtcDate(); const song = getDailySong(date); const obfuscationKey = getObfuscationKey(date); const songJson = JSON.stringify(song); - const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey); + const obfuscatedData = xorBuffer(Buffer.from(songJson, "utf8"), obfuscationKey); + + const initialState: SignedState = { + date, + currentTry: 0, + didGuess: false, + guesses: [], + }; + res.json({ date, - data: obfuscatedData.toString('hex'), + data: obfuscatedData.toString("hex"), + sessionToken: buildSessionToken(date), + initialSig: signState(initialState), + }); +}); + +app.post("/guess", guessLimiter, (req, res) => { + const today = getUtcDate(); + const body = req.body as { + sessionToken?: unknown; + state?: unknown; + sig?: unknown; + guess?: unknown; + }; + + if (!verifySessionToken(body.sessionToken, today)) { + res.status(401).json({ error: "Invalid session token." }); + return; + } + + const state = normalizeSignedState(body.state); + if (!state) { + res.status(400).json({ error: "Invalid state payload." }); + return; + } + + if (state.date !== today) { + res.status(409).json({ error: "State date is not valid for today." }); + return; + } + + if (!verifyStateSignature(state, body.sig)) { + res.status(403).json({ error: "State signature mismatch." }); + return; + } + + if (state.didGuess || state.currentTry >= 6) { + res.status(409).json({ error: "Round already finished." }); + return; + } + + let nextGuess: GuessEntry; + const guess = body.guess as { artist?: unknown; name?: unknown } | null | undefined; + + if (guess == null) { + nextGuess = { + state: "Skipped", + }; + } else if (typeof guess.artist === "string" && typeof guess.name === "string") { + const solution = getDailySong(today); + + if (guess.artist === solution.artist && guess.name === solution.name) { + nextGuess = { + song: { artist: guess.artist, name: guess.name }, + state: "Correct", + }; + } else if (guess.artist === solution.artist) { + nextGuess = { + song: { artist: guess.artist, name: guess.name }, + state: "PartiallyCorrect", + }; + } else { + nextGuess = { + song: { artist: guess.artist, name: guess.name }, + state: "Incorrect", + }; + } + } else { + res.status(400).json({ error: "Invalid guess payload." }); + return; + } + + const nextState: SignedState = { + date: state.date, + currentTry: Math.min(6, state.currentTry + 1), + didGuess: state.didGuess || nextGuess.state === "Correct", + guesses: [...state.guesses, nextGuess], + }; + + res.json({ + state: nextState, + sig: signState(nextState), + guessState: nextGuess.state, }); }); -app.get('/select', (_req, res) => { +app.get("/select", (_req, res) => { const song = songs[Math.floor(Math.random() * songs.length)]; const obfuscationKey = getObfuscationKey(); const songJson = JSON.stringify(song); - const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey); + const obfuscatedData = xorBuffer(Buffer.from(songJson, "utf8"), obfuscationKey); res.json({ - data: obfuscatedData.toString('hex'), + data: obfuscatedData.toString("hex"), }); }); -app.get('/songs', (_req, res) => { +app.get("/songs", (_req, res) => { res.json(songs.map(({ artist, name }) => ({ artist, name }))); }); - app.listen(SERVER_PORT, () => console.log(`Server running on :${SERVER_PORT}`)); |
