diff options
| -rw-r--r-- | server/daily.ts | 123 | ||||
| -rw-r--r-- | server/index.ts | 321 | ||||
| -rw-r--r-- | server/select.ts | 13 | ||||
| -rw-r--r-- | server/shared.ts | 209 |
4 files changed, 350 insertions, 316 deletions
diff --git a/server/daily.ts b/server/daily.ts new file mode 100644 index 0000000..35350f7 --- /dev/null +++ b/server/daily.ts @@ -0,0 +1,123 @@ +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { + buildSessionToken, + getDailySong, + getUtcDate, + GuessEntry, + normalizeSignedState, + obfuscateSong, + SignedState, + signState, + verifySessionToken, + verifyStateSignature, +} from "./shared"; + +export const dailyRouter = Router(); + +const guessLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many requests. Please slow down." }, +}); + +dailyRouter.get("/today", (_req, res) => { + const date = getUtcDate(); + const song = getDailySong(date); + + const initialState: SignedState = { + date, + currentTry: 0, + didGuess: false, + guesses: [], + }; + + res.json({ + date, + data: obfuscateSong(song, date), + sessionToken: buildSessionToken(date), + initialSig: signState(initialState), + }); +}); + +dailyRouter.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, + }); +}); diff --git a/server/index.ts b/server/index.ts index 3737105..c1ab788 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,9 +1,9 @@ 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"; +import { songs } from "./data/songs"; +import { dailyRouter } from "./daily"; +import { selectRouter } from "./select"; dotenv.config(); @@ -12,320 +12,9 @@ app.use(cors()); app.use(express.json()); const SERVER_PORT = process.env.SERVER_PORT || 3001; -const SALT = process.env.VITE_HEARDLE_SALT ?? "changeme"; -const SIGNING_SECRET = process.env.HEARDLE_SIGNING_SECRET ?? SALT; - -type Song = (typeof songs)[number]; - -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); -} - -function xorBuffer(data: Buffer, key: Buffer): Buffer { - const output = Buffer.alloc(data.length); - for (let i = 0; i < data.length; i++) { - output[i] = data[i] ^ key[i % key.length]; - } - return output; -} - -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++) { - hash = (hash * 31 + str.charCodeAt(i)) >>> 0; - } - return hash; -} - -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; - } -} - -function buildSessionToken(date: string): string { - const payload = Buffer.from(JSON.stringify({ date }), "utf8").toString("base64url"); - const sig = signValue("session", payload); - return `${payload}.${sig}`; -} - -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 normalizeGuessEntry(entry: unknown): GuessEntry | null { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; - - const maybeState = (entry as { state?: unknown }).state; - if ( - maybeState !== "Correct" && - maybeState !== "PartiallyCorrect" && - maybeState !== "Incorrect" && - maybeState !== "Skipped" - ) { - return null; - } - - 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, - }; -} - -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 initialState: SignedState = { - date, - currentTry: 0, - didGuess: false, - guesses: [], - }; - - res.json({ - date, - 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) => { - 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); - res.json({ - data: obfuscatedData.toString("hex"), - }); -}); +app.use(dailyRouter); +app.use(selectRouter); app.get("/songs", (_req, res) => { res.json(songs.map(({ artist, name }) => ({ artist, name }))); diff --git a/server/select.ts b/server/select.ts new file mode 100644 index 0000000..d209c38 --- /dev/null +++ b/server/select.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { songs } from "./data/songs"; +import { obfuscateSong } from "./shared"; + +export const selectRouter = Router(); + +selectRouter.get("/select", (_req, res) => { + const song = songs[Math.floor(Math.random() * songs.length)]; + + res.json({ + data: obfuscateSong(song), + }); +}); diff --git a/server/shared.ts b/server/shared.ts new file mode 100644 index 0000000..b3fad21 --- /dev/null +++ b/server/shared.ts @@ -0,0 +1,209 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { songs } from "./data/songs"; + +function getSalt(): string { + return process.env.VITE_HEARDLE_SALT ?? "changeme"; +} + +function getSigningSecret(): string { + return process.env.HEARDLE_SIGNING_SECRET ?? getSalt(); +} + +export type Song = (typeof songs)[number]; + +export type GuessResult = "Correct" | "PartiallyCorrect" | "Incorrect" | "Skipped"; + +export type GuessEntry = { + song?: { + artist: string; + name: string; + }; + state: GuessResult; +}; + +export type SignedState = { + date: string; + currentTry: number; + didGuess: boolean; + guesses: GuessEntry[]; +}; + +export function getObfuscationKey(date = getUtcDate()): Buffer { + return Buffer.from(getSalt() + date); +} + +export function xorBuffer(data: Buffer, key: Buffer): Buffer { + const output = Buffer.alloc(data.length); + for (let i = 0; i < data.length; i++) { + output[i] = data[i] ^ key[i % key.length]; + } + return output; +} + +export 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++) { + hash = (hash * 31 + str.charCodeAt(i)) >>> 0; + } + return hash; +} + +export 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", getSigningSecret()) + .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; + } +} + +export function buildSessionToken(date: string): string { + const payload = Buffer.from(JSON.stringify({ date }), "utf8").toString("base64url"); + const sig = signValue("session", payload); + return `${payload}.${sig}`; +} + +export 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 normalizeGuessEntry(entry: unknown): GuessEntry | null { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + + const maybeState = (entry as { state?: unknown }).state; + if ( + maybeState !== "Correct" && + maybeState !== "PartiallyCorrect" && + maybeState !== "Incorrect" && + maybeState !== "Skipped" + ) { + return null; + } + + 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, + }; +} + +export 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, + })), + }); +} + +export function signState(state: SignedState): string { + return signValue("state", canonicalState(state)); +} + +export function verifyStateSignature(state: SignedState, sig: unknown): boolean { + if (typeof sig !== "string") return false; + const expectedSig = signState(state); + return safeEqualHex(sig, expectedSig); +} + +export function obfuscateSong(song: Song, date?: string): string { + const obfuscationKey = getObfuscationKey(date); + const songJson = JSON.stringify(song); + const obfuscatedData = xorBuffer(Buffer.from(songJson, "utf8"), obfuscationKey); + return obfuscatedData.toString("hex"); +} |
