aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-06 13:25:10 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-06 13:25:10 -0700
commitbc183a487c97b96041507091854958fc78d22011 (patch)
tree6b6c9d540bd4af05e173bbf1d38de60ea6f4a805 /server
parentd3bd7f522ef750d809e9cdb6067a34dc3d1a9273 (diff)
server: seperate backend logic into routers
Diffstat (limited to 'server')
-rw-r--r--server/daily.ts123
-rw-r--r--server/index.ts321
-rw-r--r--server/select.ts13
-rw-r--r--server/shared.ts209
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");
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage