aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--playlist_generator/generate_daily.py33
-rw-r--r--pnpm-lock.yaml18
-rw-r--r--server/index.ts307
-rw-r--r--src/helpers/fetchSolution.ts59
-rw-r--r--src/hooks/useGameState.ts313
-rw-r--r--src/pages/DailyPage.tsx8
-rw-r--r--src/react-app-env.d.ts5
8 files changed, 578 insertions, 166 deletions
diff --git a/package.json b/package.json
index 7b07c3e..ccd7ad9 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "express-rate-limit": "^8.5.2",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
"react": "^19.2.7",
diff --git a/playlist_generator/generate_daily.py b/playlist_generator/generate_daily.py
index aba73b0..1e6e39e 100644
--- a/playlist_generator/generate_daily.py
+++ b/playlist_generator/generate_daily.py
@@ -15,15 +15,21 @@ ACCESS_KEY = os.getenv("R2_ACCESS_KEY")
SECRET_KEY = os.getenv("R2_SECRET_KEY")
BUCKET = os.getenv("R2_BUCKET")
API_URL = os.getenv("API_URL")
-OBFUSCATION_KEY = os.getenv("OBFUSCATION_KEY")
+HEARDLE_SALT = (
+ os.getenv("VITE_HEARDLE_SALT")
+ or os.getenv("OBFUSCATION_KEY")
+)
def xor_buffer(data: bytes, key: bytes) -> bytes:
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
-def get_obfuscation_key() -> bytes:
- date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
- return (OBFUSCATION_KEY + date).encode("utf-8")
+def get_obfuscation_key(date: str) -> bytes:
+ if not HEARDLE_SALT:
+ raise ValueError(
+ "Missing HEARDLE salt. Set VITE_HEARDLE_SALT (preferred) or OBFUSCATION_KEY."
+ )
+ return (HEARDLE_SALT + date).encode("utf-8")
def delete_file(file_path):
if os.path.exists(file_path):
@@ -31,18 +37,25 @@ def delete_file(file_path):
return True
return False
-def decode_data(hex_data: str):
+def decode_data(hex_data: str, date: str):
encrypted = bytes.fromhex(hex_data)
- key = get_obfuscation_key()
+ key = get_obfuscation_key(date)
decrypted = xor_buffer(encrypted, key)
return json.loads(decrypted.decode("utf-8"))
def fetch_daily() -> dict:
+ if not API_URL:
+ raise ValueError("Missing API_URL in environment.")
+
url = f"{API_URL}/today"
- response = requests.get(url)
+ response = requests.get(url, timeout=15)
response.raise_for_status()
- return response.json()
+ payload = response.json()
+ if "date" not in payload or "data" not in payload:
+ raise ValueError(f"Unexpected /today response shape: {payload}")
+
+ return payload
def download_random_segment_mp3(youtube_id: str, output_file="today.mp3") -> str:
@@ -116,11 +129,11 @@ def main():
else:
new_data = True
daily_data = fetch_daily()
- data = decode_data(daily_data["data"])
+ data = decode_data(daily_data["data"], daily_data["date"])
print(data)
youtube_id = data["youtubeId"]
clip_path = download_random_segment_mp3(youtube_id)
- date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ date = daily_data["date"]
upload_to_r2(clip_path, f"kheardle/{date}.mp3")
delete_file("today.mp3")
write_json("save.json", daily_data)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 828f20b..c30d2fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -43,6 +43,9 @@ dependencies:
express:
specifier: ^5.2.1
version: 5.2.1
+ express-rate-limit:
+ specifier: ^8.5.2
+ version: 8.5.2(express@5.2.1)
lodash:
specifier: ^4.18.1
version: 4.18.1
@@ -1816,6 +1819,16 @@ packages:
jest-util: 30.4.1
dev: false
+ /express-rate-limit@8.5.2(express@5.2.1):
+ resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+ dependencies:
+ express: 5.2.1
+ ip-address: 10.2.0
+ dev: false
+
/express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@@ -2071,6 +2084,11 @@ packages:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: false
+ /ip-address@10.2.0:
+ resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
+ engines: {node: '>= 12'}
+ dev: false
+
/ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
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}`));
diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts
index e3010fe..d877d8f 100644
--- a/src/helpers/fetchSolution.ts
+++ b/src/helpers/fetchSolution.ts
@@ -1,17 +1,17 @@
import { Song } from "../types/song";
+import { GuessState, GuessType } from "../types/guess";
-const SALT = import.meta.env.VITE_HEARDLE_SALT ?? 'changeme';
-const API_URL = import.meta.env.VITE_HEARDLE_API_URL ?? 'http://localhost:3001';
+const SALT = import.meta.env.VITE_HEARDLE_SALT ?? "changeme";
+const API_URL = import.meta.env.VITE_HEARDLE_API_URL ?? "http://localhost:3001";
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
-return bytes;
+ return bytes;
}
-
function xor(data: Uint8Array, key: Uint8Array): Uint8Array {
const output = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
@@ -20,7 +20,7 @@ function xor(data: Uint8Array, key: Uint8Array): Uint8Array {
return output;
}
-function getObfuscationKey(date = new Date().toISOString().split('T')[0]): Uint8Array {
+function getObfuscationKey(date = new Date().toISOString().split("T")[0]): Uint8Array {
return new TextEncoder().encode(SALT + date);
}
@@ -34,6 +34,33 @@ function decryptResponse(data: string, date?: string): Song {
export interface DailySolution {
date: string;
song: Song;
+ sessionToken: string;
+ initialSig: string;
+}
+
+export interface DailyGameState {
+ date: string;
+ currentTry: number;
+ didGuess: boolean;
+ guesses: GuessType[];
+}
+
+interface SongGuessPayload {
+ artist: string;
+ name: string;
+}
+
+interface SubmitDailyGuessRequest {
+ sessionToken: string;
+ state: DailyGameState;
+ sig: string;
+ guess?: SongGuessPayload | null;
+}
+
+interface SubmitDailyGuessResponse {
+ state: DailyGameState;
+ sig: string;
+ guessState: GuessState;
}
export async function getDailySolution(): Promise<DailySolution> {
@@ -41,13 +68,33 @@ export async function getDailySolution(): Promise<DailySolution> {
if (!solutionData.ok) {
throw new Error(`Failed to fetch solution: ${solutionData.statusText}`);
}
- const { data, date } = await solutionData.json();
+ const { data, date, sessionToken, initialSig } = await solutionData.json();
return {
date,
+ sessionToken,
+ initialSig,
song: decryptResponse(data, date),
};
}
+export async function submitDailyGuess(
+ payload: SubmitDailyGuessRequest
+): Promise<SubmitDailyGuessResponse> {
+ const response = await fetch(`${API_URL}/guess`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to submit guess: ${response.statusText}`);
+ }
+
+ return (await response.json()) as SubmitDailyGuessResponse;
+}
+
export async function getSelectSolution(): Promise<Song> {
const solutionData = await fetch(`${API_URL}/select`);
if (!solutionData.ok) {
diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts
index 625dddf..f4b3763 100644
--- a/src/hooks/useGameState.ts
+++ b/src/hooks/useGameState.ts
@@ -1,21 +1,33 @@
import React from "react";
-import _ from "lodash";
-
import { Song } from "../types/song";
import { GuessState, GuessType } from "../types/guess";
+import { DailyGameState, submitDailyGuess } from "../helpers/fetchSolution";
interface UseGameStateOptions {
solution: Song | null;
persist: boolean;
+ sessionDate?: string;
+ sessionToken?: string;
+ initialSig?: string;
+}
+
+const STATE_VERSION = 2;
+
+interface PersistedStatsV2 extends DailyGameState {
+ version: number;
+ sig: string;
+ sessionToken: string;
}
-interface PersistedStats {
- solution: string;
+interface LegacyStatsV0 {
+ date: string;
currentTry: number;
didGuess: boolean;
guesses: GuessType[];
}
+type PersistedStats = PersistedStatsV2;
+
const initialGuess: GuessType = {
song: undefined,
state: undefined,
@@ -29,174 +41,151 @@ const isAnsweredGuess = (guess: GuessType | undefined | null) =>
const normalizeAnsweredGuesses = (guesses: unknown): GuessType[] => {
if (!Array.isArray(guesses)) return [];
- if (guesses.some((guess) => Array.isArray(guess))) return [];
-
+ if (guesses.some((g) => Array.isArray(g))) return [];
return (guesses as GuessType[]).filter(isAnsweredGuess).slice(0, 6);
};
-const SALT = import.meta.env.VITE_HEARDLE_SALT || "changeme";
-
-const getKey = (salt: string) => {
- let key = 0;
- for (let i = 0; i < salt.length; i++) {
- key = (key + salt.charCodeAt(i)) % 256;
- }
- return key;
-};
-
-const KEY = getKey(SALT);
-
function loadStats(): PersistedStats | null {
try {
const raw = localStorage.getItem("stats");
if (!raw) return null;
const parsed = JSON.parse(raw);
-
- if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
localStorage.removeItem("stats");
return null;
}
+ const isLegacyV0 =
+ !("version" in parsed) &&
+ "date" in parsed &&
+ "guesses" in parsed &&
+ !("sig" in parsed);
+
+ if (isLegacyV0) {
+ const legacy = parsed as LegacyStatsV0;
+
+ const upgraded: PersistedStats = {
+ version: STATE_VERSION,
+ date: legacy.date,
+ currentTry: Math.max(
+ 0,
+ Math.min(6, Math.floor(legacy.currentTry || 0))
+ ),
+ didGuess: !!legacy.didGuess,
+ guesses: normalizeAnsweredGuesses(legacy.guesses),
+ sig: "",
+ sessionToken: "",
+ };
+
+ return upgraded;
+ }
+
const currentTry =
typeof parsed.currentTry === "number" && Number.isFinite(parsed.currentTry)
? Math.max(0, Math.min(6, Math.floor(parsed.currentTry)))
: 0;
- const normalized: PersistedStats = {
- solution: typeof parsed.solution === "string" ? parsed.solution : "",
- currentTry,
+ const normalizedGuesses = normalizeAnsweredGuesses(parsed.guesses);
+
+ return {
+ version: typeof parsed.version === "number" ? parsed.version : STATE_VERSION,
+ date: typeof parsed.date === "string" ? parsed.date : "",
+ currentTry: Math.min(currentTry, normalizedGuesses.length),
didGuess: !!parsed.didGuess,
- guesses: normalizeAnsweredGuesses(parsed.guesses),
+ guesses: normalizedGuesses,
+ sig: typeof parsed.sig === "string" ? parsed.sig : "",
+ sessionToken:
+ typeof parsed.sessionToken === "string" ? parsed.sessionToken : "",
};
-
- return normalized;
} catch {
localStorage.removeItem("stats");
return null;
}
}
-const encodeSolution = (solution: Song) => {
- const json = JSON.stringify(solution);
-
- const xored = json
- .split("")
- .map((c) => String.fromCharCode(c.charCodeAt(0) ^ KEY))
- .join("");
-
- return btoa(xored);
-};
-
-const decodeSolution = (value: string): Song | null => {
- try {
- const decoded = atob(value);
-
- const json = decoded
- .split("")
- .map((c) => String.fromCharCode(c.charCodeAt(0) ^ KEY))
- .join("");
-
- return JSON.parse(json);
- } catch {
- return null;
- }
-};
-
-export function useGameState({ solution, persist }: UseGameStateOptions) {
+export function useGameState({
+ solution,
+ persist,
+ sessionDate,
+ sessionToken,
+ initialSig,
+}: UseGameStateOptions) {
const [guesses, setGuesses] = React.useState<GuessType[]>(makeEmptyGuesses());
-
const [currentTry, setCurrentTry] = React.useState(0);
const [selectedSong, setSelectedSong] = React.useState<Song>();
const [didGuess, setDidGuess] = React.useState(false);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
const [stats, setStats] = React.useState<PersistedStats | null>(() =>
loadStats()
);
const hydratedRef = React.useRef(false);
- const skipNextStatsSyncRef = React.useRef(false);
React.useEffect(() => {
- if (!persist || !solution) return;
+ if (!persist) return;
if (hydratedRef.current) return;
+ if (!sessionDate || !sessionToken || !initialSig) return;
- skipNextStatsSyncRef.current = true;
-
- const initialStats: PersistedStats = {
- solution: encodeSolution(solution),
+ const baseStats: PersistedStats = {
+ version: STATE_VERSION,
+ date: sessionDate,
currentTry: 0,
didGuess: false,
guesses: [],
+ sig: initialSig,
+ sessionToken,
};
if (!stats) {
- setStats(initialStats);
+ setStats(baseStats);
+ setCurrentTry(0);
+ setGuesses(makeEmptyGuesses());
+ setDidGuess(false);
hydratedRef.current = true;
return;
}
- const decodedSolution = stats.solution
- ? decodeSolution(stats.solution)
- : null;
+ const sameSession =
+ stats.date === sessionDate &&
+ stats.sessionToken === sessionToken;
- const sameGame = _.isEqual(solution, decodedSolution);
+ if (sameSession) {
+ const answered = normalizeAnsweredGuesses(stats.guesses);
+ const hydrated = makeEmptyGuesses();
- if (sameGame) {
- const answeredGuesses = normalizeAnsweredGuesses(stats.guesses);
- const hydratedGuesses = makeEmptyGuesses();
+ answered.forEach((g, i) => (hydrated[i] = g));
- answeredGuesses.forEach((guess, index) => {
- hydratedGuesses[index] = guess;
- });
-
- const normalizedCurrentTry = Math.min(
- answeredGuesses.length,
- Math.max(0, stats.currentTry ?? 0)
+ const normalizedTry = Math.min(
+ answered.length,
+ Math.max(0, stats.currentTry)
);
- setCurrentTry(normalizedCurrentTry);
- setGuesses(hydratedGuesses);
+ setCurrentTry(normalizedTry);
+ setGuesses(hydrated);
setDidGuess(!!stats.didGuess);
+
setStats({
...stats,
- currentTry: normalizedCurrentTry,
- guesses: answeredGuesses,
+ version: STATE_VERSION,
+ currentTry: normalizedTry,
+ guesses: answered,
});
} else {
- setStats(initialStats);
+ setStats(baseStats);
setCurrentTry(0);
setGuesses(makeEmptyGuesses());
setDidGuess(false);
}
hydratedRef.current = true;
- }, [solution, persist]);
+ }, [persist, sessionDate, sessionToken, initialSig, stats]);
React.useEffect(() => {
if (!persist) return;
if (!hydratedRef.current) return;
- if (skipNextStatsSyncRef.current) {
- skipNextStatsSyncRef.current = false;
- return;
- }
-
- setStats((prev) => {
- if (!prev) return prev;
-
- return {
- ...prev,
- currentTry,
- didGuess,
- guesses: guesses.filter(isAnsweredGuess),
- };
- });
- }, [guesses, currentTry, didGuess, persist]);
-
- React.useEffect(() => {
- if (!persist) return;
- if (!hydratedRef.current) return;
if (!stats) {
localStorage.removeItem("stats");
return;
@@ -205,9 +194,65 @@ export function useGameState({ solution, persist }: UseGameStateOptions) {
localStorage.setItem("stats", JSON.stringify(stats));
}, [stats, persist]);
- const skip = React.useCallback(() => {
+ const applyServerState = React.useCallback(
+ (next: DailyGameState, sig: string) => {
+ if (!sessionToken) return;
+
+ const answered = normalizeAnsweredGuesses(next.guesses);
+ const hydrated = makeEmptyGuesses();
+
+ answered.forEach((g, i) => (hydrated[i] = g));
+
+ const normalizedTry = Math.min(
+ answered.length,
+ Math.max(0, next.currentTry)
+ );
+
+ setCurrentTry(normalizedTry);
+ setGuesses(hydrated);
+ setDidGuess(!!next.didGuess);
+ setSelectedSong(undefined);
+
+ setStats({
+ version: STATE_VERSION,
+ date: next.date,
+ currentTry: normalizedTry,
+ didGuess: !!next.didGuess,
+ guesses: answered,
+ sig,
+ sessionToken,
+ });
+ },
+ [sessionToken]
+ );
+
+ const skip = React.useCallback(async () => {
if (didGuess || currentTry >= guesses.length) return;
+ if (persist && sessionDate && sessionToken) {
+ if (!stats?.sig || isSubmitting) return;
+
+ setIsSubmitting(true);
+ try {
+ const res = await submitDailyGuess({
+ sessionToken,
+ sig: stats.sig,
+ state: {
+ date: sessionDate,
+ currentTry,
+ didGuess,
+ guesses: guesses.filter(isAnsweredGuess),
+ },
+ guess: null,
+ });
+
+ applyServerState(res.state, res.sig);
+ } finally {
+ setIsSubmitting(false);
+ }
+ return;
+ }
+
setGuesses((prev) => {
const copy = [...prev];
copy[currentTry] = {
@@ -218,12 +263,49 @@ export function useGameState({ solution, persist }: UseGameStateOptions) {
});
setCurrentTry((t) => t + 1);
- }, [currentTry, didGuess, guesses.length]);
+ }, [
+ didGuess,
+ currentTry,
+ guesses,
+ persist,
+ sessionDate,
+ sessionToken,
+ stats,
+ isSubmitting,
+ applyServerState,
+ ]);
- const guess = React.useCallback(() => {
+ const guess = React.useCallback(async () => {
if (!selectedSong || !solution) return;
if (didGuess || currentTry >= guesses.length) return;
+ if (persist && sessionDate && sessionToken) {
+ if (!stats?.sig || isSubmitting) return;
+
+ setIsSubmitting(true);
+ try {
+ const res = await submitDailyGuess({
+ sessionToken,
+ sig: stats.sig,
+ state: {
+ date: sessionDate,
+ currentTry,
+ didGuess,
+ guesses: guesses.filter(isAnsweredGuess),
+ },
+ guess: {
+ artist: selectedSong.artist,
+ name: selectedSong.name,
+ },
+ });
+
+ applyServerState(res.state, res.sig);
+ } finally {
+ setIsSubmitting(false);
+ }
+ return;
+ }
+
let state = GuessState.Incorrect;
if (
@@ -237,20 +319,27 @@ export function useGameState({ solution, persist }: UseGameStateOptions) {
setGuesses((prev) => {
const copy = [...prev];
- copy[currentTry] = {
- song: selectedSong,
- state,
- };
+ copy[currentTry] = { song: selectedSong, state };
return copy;
});
setCurrentTry((t) => t + 1);
setSelectedSong(undefined);
- if (state === GuessState.Correct) {
- setDidGuess(true);
- }
- }, [selectedSong, solution, currentTry, didGuess, guesses.length]);
+ if (state === GuessState.Correct) setDidGuess(true);
+ }, [
+ selectedSong,
+ solution,
+ currentTry,
+ didGuess,
+ guesses,
+ persist,
+ sessionDate,
+ sessionToken,
+ stats,
+ isSubmitting,
+ applyServerState,
+ ]);
const reset = React.useCallback(() => {
setGuesses(makeEmptyGuesses());
diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx
index 24ac20c..d1482b1 100644
--- a/src/pages/DailyPage.tsx
+++ b/src/pages/DailyPage.tsx
@@ -69,7 +69,13 @@ export function DailyPage() {
didGuess,
skip,
guess,
- } = useGameState({ solution: todaysSolution?.song ?? null, persist: true });
+ } = useGameState({
+ solution: todaysSolution?.song ?? null,
+ persist: true,
+ sessionDate: todaysSolution?.date,
+ sessionToken: todaysSolution?.sessionToken,
+ initialSig: todaysSolution?.initialSig,
+ });
const [isInfoPopUpOpen, setIsInfoPopUpOpen] =
React.useState<boolean>(firstRun);
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
index f6733c9..ac88680 100644
--- a/src/react-app-env.d.ts
+++ b/src/react-app-env.d.ts
@@ -1,10 +1,15 @@
/// <reference types="react-scripts" />
+
declare global {
interface ImportMetaEnv {
readonly VITE_CDN_URL?: string;
+ readonly VITE_HEARDLE_SALT?: string;
+ readonly VITE_HEARDLE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
}
+
+export {};
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage