diff --git a/.env.development b/.env.development
index 2086b1a4b7..badc209a2a 100644
--- a/.env.development
+++ b/.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_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
@@ -37,3 +37,11 @@ VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true
+
+VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
+7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
+ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
+UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
+s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
+kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
+HQIDAQAB'
diff --git a/.env.production b/.env.production
index 64e696847f..9ccb8d6fcc 100644
--- a/.env.production
+++ b/.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_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'
diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx
index 9b7eadff84..d7a93bbea0 100644
--- a/excalidraw-app/App.tsx
+++ b/excalidraw-app/App.tsx
@@ -126,6 +126,7 @@ import DebugCanvas, {
loadSavedDebugState,
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
+import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
polyfill();
@@ -1125,6 +1126,12 @@ const ExcalidrawWrapper = () => {
};
const ExcalidrawApp = () => {
+ const isCloudExportWindow =
+ window.location.pathname === "/excalidraw-plus-export";
+ if (isCloudExportWindow) {
+ return ;
+ }
+
return (
appJotaiStore}>
diff --git a/excalidraw-app/ExcalidrawPlusIframeExport.tsx b/excalidraw-app/ExcalidrawPlusIframeExport.tsx
new file mode 100644
index 0000000000..64ebdeb60d
--- /dev/null
+++ b/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;
+ 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;
+};
diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts
index 44e6b99749..15dfdb2c0d 100644
--- a/packages/excalidraw/data/encode.ts
+++ b/packages/excalidraw/data/encode.ts
@@ -65,6 +65,20 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
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
// -----------------------------------------------------------------------------
diff --git a/packages/excalidraw/errors.ts b/packages/excalidraw/errors.ts
index 8509deb52f..d6091b0e91 100644
--- a/packages/excalidraw/errors.ts
+++ b/packages/excalidraw/errors.ts
@@ -50,6 +50,7 @@ export class WorkerUrlNotDefinedError extends Error {
this.code = code;
}
}
+
export class WorkerInTheMainChunkError extends Error {
public code;
constructor(
@@ -61,3 +62,14 @@ export class WorkerInTheMainChunkError extends Error {
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";
+ }
+}