diff --git a/src/lib/shareLink.ts b/src/lib/shareLink.ts new file mode 100644 index 00000000..cfbc2750 --- /dev/null +++ b/src/lib/shareLink.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + const params = new URLSearchParams({ pgn: await encodePgnParam(pgn) }); + if (orientation === "black") params.set("orientation", "black"); + + return `${window.location.origin}/?${params.toString()}`; +}; diff --git a/src/sections/analysis/panelHeader/loadGame.tsx b/src/sections/analysis/panelHeader/loadGame.tsx index 30cd657c..22650a61 100644 --- a/src/sections/analysis/panelHeader/loadGame.tsx +++ b/src/sections/analysis/panelHeader/loadGame.tsx @@ -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, @@ -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(); @@ -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(undefined); const joinedGameHistory = useMemo(() => game.history().join(), [game]); @@ -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); } }; @@ -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 ( - { - await router.replace( - { - query: {}, - pathname: router.pathname, - }, - undefined, - { shallow: true, scroll: false } - ); - resetAndSetGamePgn(game.pgn()); - }} - /> + <> + + + Loading shared game... + + + + + setLoadError("")} + severity="error" + variant="filled" + sx={{ width: "100%" }} + > + {loadError} + + + + {!evaluationProgress && ( + { + await router.replace( + { + query: {}, + pathname: router.pathname, + }, + undefined, + { shallow: true, scroll: false } + ); + resetAndSetGamePgn(game.pgn()); + }} + /> + )} + ); } diff --git a/src/sections/analysis/panelToolbar/index.tsx b/src/sections/analysis/panelToolbar/index.tsx index db518b9b..e9ca2e08 100644 --- a/src/sections/analysis/panelToolbar/index.tsx +++ b/src/sections/analysis/panelToolbar/index.tsx @@ -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); @@ -69,6 +70,7 @@ export default function PanelToolBar() { {isSmOrGreater && ( <> + )} @@ -83,6 +85,7 @@ export default function PanelToolBar() { > + )} diff --git a/src/sections/analysis/panelToolbar/shareButton.tsx b/src/sections/analysis/panelToolbar/shareButton.tsx new file mode 100644 index 00000000..6016799d --- /dev/null +++ b/src/sections/analysis/panelToolbar/shareButton.tsx @@ -0,0 +1,83 @@ +import { useAtomValue } from "jotai"; +import { useEffect, useState } from "react"; +import { Alert, Snackbar } from "@mui/material"; +import { boardOrientationAtom, gameAtom } from "../states"; +import { ToolbarButton } from "@/components/ToolbarButton"; +import { buildPgnShareUrl } from "@/lib/shareLink"; + +export const ShareButton = () => { + const game = useAtomValue(gameAtom); + const boardOrientation = useAtomValue(boardOrientationAtom); + const [shareUrl, setShareUrl] = useState(""); + const [feedback, setFeedback] = useState<{ + message: string; + severity: "success" | "error"; + } | null>(null); + + // Building the link is async because the PGN is gzipped, and Safari drops the + // user activation across an await — clipboard writes from the click handler + // would fail with NotAllowedError. Prepare the link up front so the handler + // itself stays synchronous, like CopyPgnButton. + useEffect(() => { + if (game.history().length === 0) { + setShareUrl(""); + return; + } + + let cancelled = false; + + buildPgnShareUrl(game.pgn(), boardOrientation ? "white" : "black") + .then((url) => { + if (!cancelled) setShareUrl(url); + }) + .catch((error) => { + console.error("Unable to build share link", error); + if (!cancelled) setShareUrl(""); + }); + + return () => { + cancelled = true; + }; + }, [game, boardOrientation]); + + const handleShare = () => { + navigator.clipboard + ?.writeText?.(shareUrl) + ?.then(() => + setFeedback({ message: "Share link copied !", severity: "success" }) + ) + ?.catch((error) => { + console.error(error); + setFeedback({ + message: "Unable to copy the share link", + severity: "error", + }); + }); + }; + + return ( + <> + + + setFeedback(null)} + > + setFeedback(null)} + severity={feedback?.severity ?? "success"} + variant="filled" + sx={{ width: "100%" }} + > + {feedback?.message} + + + + ); +};