import { useLayoutEffect, useRef } from "react"; import { STORAGE_KEYS } from "./app_constants"; import { LocalData } from "./data/LocalData"; import type { FileId, OrderedExcalidrawElement, } from "../packages/excalidraw/element/types"; import type { AppState, BinaryFileData } from "../packages/excalidraw/types"; import { ExcalidrawError } from "../packages/excalidraw/errors"; import { base64urlToString } from "../packages/excalidraw/data/encode"; const EVENT_REQUEST_SCENE = "REQUEST_SCENE"; const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP; // ----------------------------------------------------------------------------- // outgoing message // ----------------------------------------------------------------------------- type MESSAGE_REQUEST_SCENE = { type: "REQUEST_SCENE"; jwt: string; }; type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE; // incoming messages // ----------------------------------------------------------------------------- type MESSAGE_READY = { type: "READY" }; type MESSAGE_ERROR = { type: "ERROR"; message: string }; type MESSAGE_SCENE_DATA = { type: "SCENE_DATA"; elements: OrderedExcalidrawElement[]; appState: Pick; files: { loadedFiles: BinaryFileData[]; erroredFiles: Map }; }; type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY; // ----------------------------------------------------------------------------- const parseSceneData = async ({ rawElementsString, rawAppStateString, }: { rawElementsString: string | null; rawAppStateString: string | null; }): Promise => { if (!rawElementsString || !rawAppStateString) { throw new ExcalidrawError("Elements or appstate is missing."); } try { const elements = JSON.parse( rawElementsString, ) as OrderedExcalidrawElement[]; if (!elements.length) { throw new ExcalidrawError("Scene is empty, nothing to export."); } const appState = JSON.parse(rawAppStateString) as Pick< AppState, "viewBackgroundColor" >; const fileIds = elements.reduce((acc, el) => { if ("fileId" in el && el.fileId) { acc.push(el.fileId); } return acc; }, [] as FileId[]); const files = await LocalData.fileStorage.getFiles(fileIds); return { type: "SCENE_DATA", elements, appState, files, }; } catch (error: any) { throw error instanceof ExcalidrawError ? error : new ExcalidrawError("Failed to parse scene data."); } }; const verifyJWT = async ({ token, publicKey, }: { token: string; publicKey: string; }) => { try { if (!publicKey) { throw new ExcalidrawError("Public key is undefined"); } const [header, payload, signature] = token.split("."); if (!header || !payload || !signature) { throw new ExcalidrawError("Invalid JWT format"); } // JWT is using Base64URL encoding const decodedPayload = base64urlToString(payload); const decodedSignature = base64urlToString(signature); const data = `${header}.${payload}`; const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) => c.charCodeAt(0), ); const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, ""); const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) => c.charCodeAt(0), ); const key = await crypto.subtle.importKey( "spki", keyArrayBuffer, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"], ); const isValid = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key, signatureArrayBuffer, new TextEncoder().encode(data), ); if (!isValid) { throw new Error("Invalid JWT"); } const parsedPayload = JSON.parse(decodedPayload); // Check for expiration const currentTime = Math.floor(Date.now() / 1000); if (parsedPayload.exp && parsedPayload.exp < currentTime) { throw new Error("JWT has expired"); } } catch (error) { console.error("Failed to verify JWT:", error); throw new Error(error instanceof Error ? error.message : "Invalid JWT"); } }; export const ExcalidrawPlusIframeExport = () => { const readyRef = useRef(false); useLayoutEffect(() => { const handleMessage = async (event: MessageEvent) => { if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) { throw new ExcalidrawError("Invalid origin"); } if (event.data.type === EVENT_REQUEST_SCENE) { if (!event.data.jwt) { throw new ExcalidrawError("JWT is missing"); } try { try { await verifyJWT({ token: event.data.jwt, publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY, }); } catch (error: any) { console.error(`Failed to verify JWT: ${error.message}`); throw new ExcalidrawError("Failed to verify JWT"); } const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({ rawAppStateString: localStorage.getItem( STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, ), rawElementsString: localStorage.getItem( STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, ), }); event.source!.postMessage(parsedSceneData, { targetOrigin: EXCALIDRAW_PLUS_ORIGIN, }); } catch (error) { const responseData: MESSAGE_ERROR = { type: "ERROR", message: error instanceof ExcalidrawError ? error.message : "Failed to export scene data", }; event.source!.postMessage(responseData, { targetOrigin: EXCALIDRAW_PLUS_ORIGIN, }); } } }; window.addEventListener("message", handleMessage); // so we don't send twice in StrictMode if (!readyRef.current) { readyRef.current = true; const message: MESSAGE_FROM_EDITOR = { type: "READY" }; window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN); } return () => { window.removeEventListener("message", handleMessage); }; }, []); // Since this component is expected to run in a hidden iframe on Excaildraw+, // it doesn't need to render anything. All the data we need is available in // LocalStorage and IndexedDB. It only needs to handle the messaging between // the parent window and the iframe with the relevant data. return null; };