Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/lib/shareLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Shareable game links.
*
* Chesskit is a static export with no backend, so a share link has to carry the
* game itself. PGNs are gzipped with the browser's CompressionStream and encoded
* as base64url, which keeps a typical Chess.com game (~3KB of PGN with clock
* annotations) down to roughly 1KB of URL.
*
* The payload is prefixed with a one character format tag so the encoding can
* change later without breaking links already in the wild.
*/

const FORMAT_GZIP = "1";
const FORMAT_RAW = "0";

/**
* Decompression is bounded as it runs, not checked afterwards: gzip reaches
* ratios near 1000:1, so a ~270KB param can inflate to 200MB and take the tab
* down well before any check on the finished string could reject it.
*/
const MAX_DECODED_PGN_BYTES = 500_000;
const MAX_PARAM_LENGTH = 100_000;

const isCompressionSupported = (): boolean =>
typeof CompressionStream === "function" &&
typeof DecompressionStream === "function";

const bytesToBase64Url = (bytes: Uint8Array): string => {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}

return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
};

const base64UrlToBytes = (value: string): Uint8Array => {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"="
);

const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}

return bytes;
};

const gzip = async (value: string): Promise<Uint8Array> => {
const stream = new Blob([value])
.stream()
.pipeThrough(new CompressionStream("gzip"));

return new Uint8Array(await new Response(stream).arrayBuffer());
};

/**
* Inflates gzip data, giving up as soon as the output passes `maxBytes` so a
* decompression bomb is abandoned mid-stream rather than fully expanded.
*/
const gunzipBounded = async (
bytes: Uint8Array,
maxBytes: number
): Promise<string | undefined> => {
const reader = new Blob([bytes])
.stream()
.pipeThrough(new DecompressionStream("gzip"))
.getReader();

const chunks: Uint8Array[] = [];
let size = 0;

for (;;) {
const { done, value } = await reader.read();
if (done) break;

size += value.length;
if (size > maxBytes) {
await reader.cancel();
return undefined;
}

chunks.push(value);
}

const decoded = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
decoded.set(chunk, offset);
offset += chunk.length;
}

return new TextDecoder().decode(decoded);
};

/** Encodes a PGN into the value used by the `pgn` query param. */
export const encodePgnParam = async (pgn: string): Promise<string> => {
if (!isCompressionSupported()) {
return FORMAT_RAW + bytesToBase64Url(new TextEncoder().encode(pgn));
}

return FORMAT_GZIP + bytesToBase64Url(await gzip(pgn));
};

/**
* Decodes a `pgn` query param back into a PGN string.
* Returns undefined for anything malformed — a bad link should land the user on
* an empty board, not a crash.
*/
export const decodePgnParam = async (
param: string
): Promise<string | undefined> => {
try {
if (param.length > MAX_PARAM_LENGTH) return undefined;

const format = param.slice(0, 1);
const payload = param.slice(1);
if (!payload) return undefined;

const bytes = base64UrlToBytes(payload);

if (format === FORMAT_RAW) {
if (bytes.length > MAX_DECODED_PGN_BYTES) return undefined;
return new TextDecoder().decode(bytes);
}

if (format === FORMAT_GZIP) {
if (!isCompressionSupported()) return undefined;
return await gunzipBounded(bytes, MAX_DECODED_PGN_BYTES);
}

return undefined;
} catch (error) {
console.error("Unable to decode shared PGN", error);
return undefined;
}
};

/** Builds a full share URL for a PGN, pointing at the analysis page. */
export const buildPgnShareUrl = async (
pgn: string,
orientation?: "white" | "black"
): Promise<string> => {
const params = new URLSearchParams({ pgn: await encodePgnParam(pgn) });
if (orientation === "black") params.set("orientation", "black");

return `${window.location.origin}/?${params.toString()}`;
};
166 changes: 125 additions & 41 deletions src/sections/analysis/panelHeader/loadGame.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import LoadGameButton from "../../loadGame/loadGameButton";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChessActions } from "@/hooks/useChessActions";
import {
boardAtom,
Expand All @@ -14,6 +14,8 @@ import { Chess } from "chess.js";
import { useRouter } from "next/router";
import { GameEval } from "@/types/eval";
import { fetchLichessGame } from "@/lib/lichess";
import { decodePgnParam, encodePgnParam } from "@/lib/shareLink";
import { Alert, Snackbar } from "@mui/material";

export default function LoadGame() {
const router = useRouter();
Expand All @@ -24,6 +26,12 @@ export default function LoadGame() {
const setEval = useSetAtom(gameEvalAtom);
const setBoardOrientation = useSetAtom(boardOrientationAtom);
const evaluationProgress = useAtomValue(evaluationProgressAtom);
const [loadError, setLoadError] = useState("");
const [isLoadingSharedGame, setIsLoadingSharedGame] = useState(false);

// Params this component wrote itself, so the loading effect can tell them
// apart from a link the user actually opened.
const publishedPgnParamRef = useRef<string | undefined>(undefined);

const joinedGameHistory = useMemo(() => game.history().join(), [game]);

Expand All @@ -41,13 +49,48 @@ export default function LoadGame() {
[joinedGameHistory, resetBoard, setGamePgn, setEval, setBoardOrientation]
);

const { lichessGameId, orientation: orientationParam } = router.query;
const {
lichessGameId,
pgn: pgnParam,
orientation: orientationParam,
} = router.query;

const hasLichessParam = typeof lichessGameId === "string" && !!lichessGameId;
const hasPgnParam = typeof pgnParam === "string" && !!pgnParam;

useEffect(() => {
const handleLichess = async (id: string) => {
const res = await fetchLichessGame(id);
if (typeof res === "string") {
resetAndSetGamePgn(res, orientationParam !== "black");
const controller = new AbortController();
const isWhiteOrientation = orientationParam !== "black";

const loadSharedGame = async () => {
setIsLoadingSharedGame(true);
setLoadError("");

try {
if (hasPgnParam) {
const pgn = await decodePgnParam(pgnParam);
if (controller.signal.aborted) return;
if (!pgn) throw new Error("This shared link is invalid or corrupted");
resetAndSetGamePgn(pgn, isWhiteOrientation);
return;
}

if (hasLichessParam) {
const res = await fetchLichessGame(lichessGameId, controller.signal);
if (controller.signal.aborted) return;
if (typeof res !== "string") {
throw new Error(`Unable to load Lichess game ${lichessGameId}`);
}
resetAndSetGamePgn(res, isWhiteOrientation);
}
} catch (error) {
if (controller.signal.aborted) return;
console.error(error);
setLoadError(
error instanceof Error ? error.message : "Unable to load shared game"
);
} finally {
if (!controller.signal.aborted) setIsLoadingSharedGame(false);
}
};

Expand All @@ -56,53 +99,94 @@ export default function LoadGame() {
gameFromUrl.site === "Chesskit.org" && gameFromUrl.black.name === "You"
);
resetAndSetGamePgn(gameFromUrl.pgn, orientation, gameFromUrl.eval);
} else if (typeof lichessGameId === "string" && !!lichessGameId) {
handleLichess(lichessGameId);
} else if (
// Skip a pgn param this component published itself: the board already
// holds that game, and reloading it would fight with the user.
!(hasPgnParam && pgnParam === publishedPgnParamRef.current) &&
(hasPgnParam || hasLichessParam)
) {
loadSharedGame();
}
}, [gameFromUrl, lichessGameId, orientationParam, resetAndSetGamePgn]);

return () => controller.abort();
}, [
gameFromUrl,
hasPgnParam,
hasLichessParam,
lichessGameId,
pgnParam,
orientationParam,
resetAndSetGamePgn,
]);

// Keep the address bar carrying the loaded game, so copying the URL shares it.
// Links that already name a source stay as they are — `?lichessGameId=x` is
// far nicer to share than a 1.5KB blob, and resolves to the same game.
useEffect(() => {
const eventHandler = (event: MessageEvent) => {
try {
if (!event?.data?.pgn) return;
const { pgn, orientation } = event.data as {
pgn: string;
orientation?: "white" | "black";
};
resetAndSetGamePgn(pgn, orientation !== "black");
} catch (error) {
console.error("Error processing message event:", error);
}
};
window.addEventListener("message", eventHandler);
if (joinedGameHistory.length === 0) return;
if (hasLichessParam) return;

let cancelled = false;

encodePgnParam(game.pgn())
.then((param) => {
if (cancelled || router.query.pgn === param) return;

publishedPgnParamRef.current = param;
router.replace(
{ pathname: router.pathname, query: { ...router.query, pgn: param } },
undefined,
{ shallow: true, scroll: false }
);
})
.catch((error) => console.error("Unable to publish share link", error));

return () => {
window.removeEventListener("message", eventHandler);
cancelled = true;
};
}, [resetAndSetGamePgn]);
}, [game, joinedGameHistory, hasLichessParam, router]);

const isGameLoaded =
gameFromUrl !== undefined ||
(!!game.getHeaders().White && game.getHeaders().White !== "?") ||
game.history().length > 0;

if (evaluationProgress) return null;

return (
<LoadGameButton
label={isGameLoaded ? "Load another game" : "Load game"}
size="small"
setGame={async (game) => {
await router.replace(
{
query: {},
pathname: router.pathname,
},
undefined,
{ shallow: true, scroll: false }
);
resetAndSetGamePgn(game.pgn());
}}
/>
<>
<Snackbar open={isLoadingSharedGame && !loadError}>
<Alert severity="info" variant="filled" sx={{ width: "100%" }}>
Loading shared game...
</Alert>
</Snackbar>

<Snackbar open={!!loadError}>
<Alert
onClose={() => setLoadError("")}
severity="error"
variant="filled"
sx={{ width: "100%" }}
>
{loadError}
</Alert>
</Snackbar>

{!evaluationProgress && (
<LoadGameButton
label={isGameLoaded ? "Load another game" : "Load game"}
size="small"
setGame={async (game) => {
await router.replace(
{
query: {},
pathname: router.pathname,
},
undefined,
{ shallow: true, scroll: false }
);
resetAndSetGamePgn(game.pgn());
}}
/>
)}
</>
);
}
3 changes: 3 additions & 0 deletions src/sections/analysis/panelToolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SaveButton from "./saveButton";
import { useEffect } from "react";
import { ToolbarButton } from "@/components/ToolbarButton";
import { CopyPgnButton } from "./copyPgnButton";
import { ShareButton } from "./shareButton";

export default function PanelToolBar() {
const board = useAtomValue(boardAtom);
Expand Down Expand Up @@ -69,6 +70,7 @@ export default function PanelToolBar() {
{isSmOrGreater && (
<>
<CopyPgnButton />
<ShareButton />
<SaveButton />
</>
)}
Expand All @@ -83,6 +85,7 @@ export default function PanelToolBar() {
>
<FlipBoardButton />
<CopyPgnButton />
<ShareButton />
<SaveButton />
</Stack>
)}
Expand Down
Loading