Bläddra i källkod

feat: export scene to e+ on workspace creation/redemption (#8514)

Co-authored-by: dwelle <[email protected]>
Barnabás Molnár 8 månader sedan
förälder
incheckning
d9ad7c039b

+ 9 - 1
.env.development

@@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
 VITE_APP_WS_SERVER_URL=http://localhost:3002
 VITE_APP_WS_SERVER_URL=http://localhost:3002
 
 
 VITE_APP_PLUS_LP=https://plus.excalidraw.com
 VITE_APP_PLUS_LP=https://plus.excalidraw.com
-VITE_APP_PLUS_APP=https://app.excalidraw.com
+VITE_APP_PLUS_APP=http://localhost:3000
 
 
 VITE_APP_AI_BACKEND=http://localhost:3015
 VITE_APP_AI_BACKEND=http://localhost:3015
 
 
@@ -37,3 +37,11 @@ VITE_APP_COLLAPSE_OVERLAY=true
 
 
 # Set this flag to false to disable eslint
 # Set this flag to false to disable eslint
 VITE_APP_ENABLE_ESLINT=true
 VITE_APP_ENABLE_ESLINT=true
+
+VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
+7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
+ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
+UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
+s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
+kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
+HQIDAQAB'

+ 8 - 0
.env.production

@@ -15,3 +15,11 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
 VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
 
 
 VITE_APP_ENABLE_TRACKING=false
 VITE_APP_ENABLE_TRACKING=false
+
+VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX
+/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t
+kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF
+gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53
+PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx
+iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE
+PQIDAQAB'

+ 7 - 0
excalidraw-app/App.tsx

@@ -126,6 +126,7 @@ import DebugCanvas, {
   loadSavedDebugState,
   loadSavedDebugState,
 } from "./components/DebugCanvas";
 } from "./components/DebugCanvas";
 import { AIComponents } from "./components/AI";
 import { AIComponents } from "./components/AI";
+import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
 
 
 polyfill();
 polyfill();
 
 
@@ -1125,6 +1126,12 @@ const ExcalidrawWrapper = () => {
 };
 };
 
 
 const ExcalidrawApp = () => {
 const ExcalidrawApp = () => {
+  const isCloudExportWindow =
+    window.location.pathname === "/excalidraw-plus-export";
+  if (isCloudExportWindow) {
+    return <ExcalidrawPlusIframeExport />;
+  }
+
   return (
   return (
     <TopErrorBoundary>
     <TopErrorBoundary>
       <Provider unstable_createStore={() => appJotaiStore}>
       <Provider unstable_createStore={() => appJotaiStore}>

+ 222 - 0
excalidraw-app/ExcalidrawPlusIframeExport.tsx

@@ -0,0 +1,222 @@
+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<AppState, "viewBackgroundColor">;
+  files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
+};
+
+type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
+// -----------------------------------------------------------------------------
+
+const parseSceneData = async ({
+  rawElementsString,
+  rawAppStateString,
+}: {
+  rawElementsString: string | null;
+  rawAppStateString: string | null;
+}): Promise<MESSAGE_SCENE_DATA> => {
+  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<MESSAGE_FROM_PLUS>) => {
+      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;
+};

+ 14 - 0
packages/excalidraw/data/encode.ts

@@ -65,6 +65,20 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
   return byteStringToArrayBuffer(atob(base64));
   return byteStringToArrayBuffer(atob(base64));
 };
 };
 
 
+// -----------------------------------------------------------------------------
+// base64url
+// -----------------------------------------------------------------------------
+
+export const base64urlToString = (str: string) => {
+  return window.atob(
+    // normalize base64URL to base64
+    str
+      .replace(/-/g, "+")
+      .replace(/_/g, "/")
+      .padEnd(str.length + ((4 - (str.length % 4)) % 4), "="),
+  );
+};
+
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------
 // text encoding
 // text encoding
 // -----------------------------------------------------------------------------
 // -----------------------------------------------------------------------------

+ 12 - 0
packages/excalidraw/errors.ts

@@ -50,6 +50,7 @@ export class WorkerUrlNotDefinedError extends Error {
     this.code = code;
     this.code = code;
   }
   }
 }
 }
+
 export class WorkerInTheMainChunkError extends Error {
 export class WorkerInTheMainChunkError extends Error {
   public code;
   public code;
   constructor(
   constructor(
@@ -61,3 +62,14 @@ export class WorkerInTheMainChunkError extends Error {
     this.code = code;
     this.code = code;
   }
   }
 }
 }
+
+/**
+ * Use this for generic, handled errors, so you can check against them
+ * and rethrow if needed
+ */
+export class ExcalidrawError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "ExcalidrawError";
+  }
+}