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"; + } +}