You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
223 lines
6.4 KiB
TypeScript
223 lines
6.4 KiB
TypeScript
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;
|
|
};
|