6 changed files with 272 additions and 1 deletions
@ -0,0 +1,222 @@
@@ -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; |
||||
}; |
||||
Loading…
Reference in new issue