ExcalidrawPlusIframeExport.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
  2. import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
  3. import { useLayoutEffect, useRef } from "react";
  4. import type {
  5. FileId,
  6. OrderedExcalidrawElement,
  7. } from "@excalidraw/element/types";
  8. import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
  9. import { STORAGE_KEYS } from "./app_constants";
  10. import { LocalData } from "./data/LocalData";
  11. const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
  12. const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP;
  13. // -----------------------------------------------------------------------------
  14. // outgoing message
  15. // -----------------------------------------------------------------------------
  16. type MESSAGE_REQUEST_SCENE = {
  17. type: "REQUEST_SCENE";
  18. jwt: string;
  19. };
  20. type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE;
  21. // incoming messages
  22. // -----------------------------------------------------------------------------
  23. type MESSAGE_READY = { type: "READY" };
  24. type MESSAGE_ERROR = { type: "ERROR"; message: string };
  25. type MESSAGE_SCENE_DATA = {
  26. type: "SCENE_DATA";
  27. elements: OrderedExcalidrawElement[];
  28. appState: Pick<AppState, "viewBackgroundColor">;
  29. files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
  30. };
  31. type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
  32. // -----------------------------------------------------------------------------
  33. const parseSceneData = async ({
  34. rawElementsString,
  35. rawAppStateString,
  36. }: {
  37. rawElementsString: string | null;
  38. rawAppStateString: string | null;
  39. }): Promise<MESSAGE_SCENE_DATA> => {
  40. if (!rawElementsString || !rawAppStateString) {
  41. throw new ExcalidrawError("Elements or appstate is missing.");
  42. }
  43. try {
  44. const elements = JSON.parse(
  45. rawElementsString,
  46. ) as OrderedExcalidrawElement[];
  47. if (!elements.length) {
  48. throw new ExcalidrawError("Scene is empty, nothing to export.");
  49. }
  50. const appState = JSON.parse(rawAppStateString) as Pick<
  51. AppState,
  52. "viewBackgroundColor"
  53. >;
  54. const fileIds = elements.reduce((acc, el) => {
  55. if ("fileId" in el && el.fileId) {
  56. acc.push(el.fileId);
  57. }
  58. return acc;
  59. }, [] as FileId[]);
  60. const files = await LocalData.fileStorage.getFiles(fileIds);
  61. return {
  62. type: "SCENE_DATA",
  63. elements,
  64. appState,
  65. files,
  66. };
  67. } catch (error: any) {
  68. throw error instanceof ExcalidrawError
  69. ? error
  70. : new ExcalidrawError("Failed to parse scene data.");
  71. }
  72. };
  73. const verifyJWT = async ({
  74. token,
  75. publicKey,
  76. }: {
  77. token: string;
  78. publicKey: string;
  79. }) => {
  80. try {
  81. if (!publicKey) {
  82. throw new ExcalidrawError("Public key is undefined");
  83. }
  84. const [header, payload, signature] = token.split(".");
  85. if (!header || !payload || !signature) {
  86. throw new ExcalidrawError("Invalid JWT format");
  87. }
  88. // JWT is using Base64URL encoding
  89. const decodedPayload = base64urlToString(payload);
  90. const decodedSignature = base64urlToString(signature);
  91. const data = `${header}.${payload}`;
  92. const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) =>
  93. c.charCodeAt(0),
  94. );
  95. const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, "");
  96. const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) =>
  97. c.charCodeAt(0),
  98. );
  99. const key = await crypto.subtle.importKey(
  100. "spki",
  101. keyArrayBuffer,
  102. { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
  103. true,
  104. ["verify"],
  105. );
  106. const isValid = await crypto.subtle.verify(
  107. "RSASSA-PKCS1-v1_5",
  108. key,
  109. signatureArrayBuffer,
  110. new TextEncoder().encode(data),
  111. );
  112. if (!isValid) {
  113. throw new Error("Invalid JWT");
  114. }
  115. const parsedPayload = JSON.parse(decodedPayload);
  116. // Check for expiration
  117. const currentTime = Math.floor(Date.now() / 1000);
  118. if (parsedPayload.exp && parsedPayload.exp < currentTime) {
  119. throw new Error("JWT has expired");
  120. }
  121. } catch (error) {
  122. console.error("Failed to verify JWT:", error);
  123. throw new Error(error instanceof Error ? error.message : "Invalid JWT");
  124. }
  125. };
  126. export const ExcalidrawPlusIframeExport = () => {
  127. const readyRef = useRef(false);
  128. useLayoutEffect(() => {
  129. const handleMessage = async (event: MessageEvent<MESSAGE_FROM_PLUS>) => {
  130. if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) {
  131. throw new ExcalidrawError("Invalid origin");
  132. }
  133. if (event.data.type === EVENT_REQUEST_SCENE) {
  134. if (!event.data.jwt) {
  135. throw new ExcalidrawError("JWT is missing");
  136. }
  137. try {
  138. try {
  139. await verifyJWT({
  140. token: event.data.jwt,
  141. publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY,
  142. });
  143. } catch (error: any) {
  144. console.error(`Failed to verify JWT: ${error.message}`);
  145. throw new ExcalidrawError("Failed to verify JWT");
  146. }
  147. const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({
  148. rawAppStateString: localStorage.getItem(
  149. STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
  150. ),
  151. rawElementsString: localStorage.getItem(
  152. STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
  153. ),
  154. });
  155. event.source!.postMessage(parsedSceneData, {
  156. targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
  157. });
  158. } catch (error) {
  159. const responseData: MESSAGE_ERROR = {
  160. type: "ERROR",
  161. message:
  162. error instanceof ExcalidrawError
  163. ? error.message
  164. : "Failed to export scene data",
  165. };
  166. event.source!.postMessage(responseData, {
  167. targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
  168. });
  169. }
  170. }
  171. };
  172. window.addEventListener("message", handleMessage);
  173. // so we don't send twice in StrictMode
  174. if (!readyRef.current) {
  175. readyRef.current = true;
  176. const message: MESSAGE_FROM_EDITOR = { type: "READY" };
  177. window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN);
  178. }
  179. return () => {
  180. window.removeEventListener("message", handleMessage);
  181. };
  182. }, []);
  183. // Since this component is expected to run in a hidden iframe on Excaildraw+,
  184. // it doesn't need to render anything. All the data we need is available in
  185. // LocalStorage and IndexedDB. It only needs to handle the messaging between
  186. // the parent window and the iframe with the relevant data.
  187. return null;
  188. };