diff options
Diffstat (limited to 'excalidraw-app/data/index.ts')
| -rw-r--r-- | excalidraw-app/data/index.ts | 338 |
1 files changed, 338 insertions, 0 deletions
diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts new file mode 100644 index 0000000..6ecb98f --- /dev/null +++ b/excalidraw-app/data/index.ts @@ -0,0 +1,338 @@ +import { + compressData, + decompressData, +} from "@excalidraw/excalidraw/data/encode"; +import { + decryptData, + generateEncryptionKey, + IV_LENGTH_BYTES, +} from "@excalidraw/excalidraw/data/encryption"; +import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; +import { restore } from "@excalidraw/excalidraw/data/restore"; +import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; +import type { SceneBounds } from "@excalidraw/excalidraw/element/bounds"; +import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers"; +import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; +import type { + ExcalidrawElement, + FileId, + OrderedExcalidrawElement, +} from "@excalidraw/excalidraw/element/types"; +import { t } from "@excalidraw/excalidraw/i18n"; +import type { + AppState, + BinaryFileData, + BinaryFiles, + SocketId, +} from "@excalidraw/excalidraw/types"; +import type { UserIdleState } from "@excalidraw/excalidraw/constants"; +import type { MakeBrand } from "@excalidraw/excalidraw/utility-types"; +import { bytesToHexString } from "@excalidraw/excalidraw/utils"; +import type { WS_SUBTYPES } from "../app_constants"; +import { + DELETED_ELEMENT_TIMEOUT, + FILE_UPLOAD_MAX_BYTES, + ROOM_ID_BYTES, +} from "../app_constants"; +import { encodeFilesForUpload } from "./FileManager"; +import { saveFilesToFirebase } from "./firebase"; + +export type SyncableExcalidrawElement = OrderedExcalidrawElement & + MakeBrand<"SyncableExcalidrawElement">; + +export const isSyncableElement = ( + element: OrderedExcalidrawElement, +): element is SyncableExcalidrawElement => { + if (element.isDeleted) { + if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) { + return true; + } + return false; + } + return !isInvisiblySmallElement(element); +}; + +export const getSyncableElements = ( + elements: readonly OrderedExcalidrawElement[], +) => + elements.filter((element) => + isSyncableElement(element), + ) as SyncableExcalidrawElement[]; + +const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL; +const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL; + +const generateRoomId = async () => { + const buffer = new Uint8Array(ROOM_ID_BYTES); + window.crypto.getRandomValues(buffer); + return bytesToHexString(buffer); +}; + +export type EncryptedData = { + data: ArrayBuffer; + iv: Uint8Array; +}; + +export type SocketUpdateDataSource = { + INVALID_RESPONSE: { + type: WS_SUBTYPES.INVALID_RESPONSE; + }; + SCENE_INIT: { + type: WS_SUBTYPES.INIT; + payload: { + elements: readonly ExcalidrawElement[]; + }; + }; + SCENE_UPDATE: { + type: WS_SUBTYPES.UPDATE; + payload: { + elements: readonly ExcalidrawElement[]; + }; + }; + MOUSE_LOCATION: { + type: WS_SUBTYPES.MOUSE_LOCATION; + payload: { + socketId: SocketId; + pointer: { x: number; y: number; tool: "pointer" | "laser" }; + button: "down" | "up"; + selectedElementIds: AppState["selectedElementIds"]; + username: string; + }; + }; + USER_VISIBLE_SCENE_BOUNDS: { + type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS; + payload: { + socketId: SocketId; + username: string; + sceneBounds: SceneBounds; + }; + }; + IDLE_STATUS: { + type: WS_SUBTYPES.IDLE_STATUS; + payload: { + socketId: SocketId; + userState: UserIdleState; + username: string; + }; + }; +}; + +export type SocketUpdateDataIncoming = + SocketUpdateDataSource[keyof SocketUpdateDataSource]; + +export type SocketUpdateData = + SocketUpdateDataSource[keyof SocketUpdateDataSource] & { + _brand: "socketUpdateData"; + }; + +const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/; + +export const isCollaborationLink = (link: string) => { + const hash = new URL(link).hash; + return RE_COLLAB_LINK.test(hash); +}; + +export const getCollaborationLinkData = (link: string) => { + const hash = new URL(link).hash; + const match = hash.match(RE_COLLAB_LINK); + if (match && match[2].length !== 22) { + window.alert(t("alerts.invalidEncryptionKey")); + return null; + } + return match ? { roomId: match[1], roomKey: match[2] } : null; +}; + +export const generateCollaborationLinkData = async () => { + const roomId = await generateRoomId(); + const roomKey = await generateEncryptionKey(); + + if (!roomKey) { + throw new Error("Couldn't generate room key"); + } + + return { roomId, roomKey }; +}; + +export const getCollaborationLink = (data: { + roomId: string; + roomKey: string; +}) => { + return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`; +}; + +/** + * Decodes shareLink data using the legacy buffer format. + * @deprecated + */ +const legacy_decodeFromBackend = async ({ + buffer, + decryptionKey, +}: { + buffer: ArrayBuffer; + decryptionKey: string; +}) => { + let decrypted: ArrayBuffer; + + try { + // Buffer should contain both the IV (fixed length) and encrypted data + const iv = buffer.slice(0, IV_LENGTH_BYTES); + const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength); + decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey); + } catch (error: any) { + // Fixed IV (old format, backward compatibility) + const fixedIv = new Uint8Array(IV_LENGTH_BYTES); + decrypted = await decryptData(fixedIv, buffer, decryptionKey); + } + + // We need to convert the decrypted array buffer to a string + const string = new window.TextDecoder("utf-8").decode( + new Uint8Array(decrypted), + ); + const data: ImportedDataState = JSON.parse(string); + + return { + elements: data.elements || null, + appState: data.appState || null, + }; +}; + +const importFromBackend = async ( + id: string, + decryptionKey: string, +): Promise<ImportedDataState> => { + try { + const response = await fetch(`${BACKEND_V2_GET}${id}`); + + if (!response.ok) { + window.alert(t("alerts.importBackendFailed")); + return {}; + } + const buffer = await response.arrayBuffer(); + + try { + const { data: decodedBuffer } = await decompressData( + new Uint8Array(buffer), + { + decryptionKey, + }, + ); + const data: ImportedDataState = JSON.parse( + new TextDecoder().decode(decodedBuffer), + ); + + return { + elements: data.elements || null, + appState: data.appState || null, + }; + } catch (error: any) { + console.warn( + "error when decoding shareLink data using the new format:", + error, + ); + return legacy_decodeFromBackend({ buffer, decryptionKey }); + } + } catch (error: any) { + window.alert(t("alerts.importBackendFailed")); + console.error(error); + return {}; + } +}; + +export const loadScene = async ( + id: string | null, + privateKey: string | null, + // Supply local state even if importing from backend to ensure we restore + // localStorage user settings which we do not persist on server. + // Non-optional so we don't forget to pass it even if `undefined`. + localDataState: ImportedDataState | undefined | null, +) => { + let data; + if (id != null && privateKey != null) { + // the private key is used to decrypt the content from the server, take + // extra care not to leak it + data = restore( + await importFromBackend(id, privateKey), + localDataState?.appState, + localDataState?.elements, + { repairBindings: true, refreshDimensions: false }, + ); + } else { + data = restore(localDataState || null, null, null, { + repairBindings: true, + }); + } + + return { + elements: data.elements, + appState: data.appState, + // note: this will always be empty because we're not storing files + // in the scene database/localStorage, and instead fetch them async + // from a different database + files: data.files, + }; +}; + +type ExportToBackendResult = + | { url: null; errorMessage: string } + | { url: string; errorMessage: null }; + +export const exportToBackend = async ( + elements: readonly ExcalidrawElement[], + appState: Partial<AppState>, + files: BinaryFiles, +): Promise<ExportToBackendResult> => { + const encryptionKey = await generateEncryptionKey("string"); + + const payload = await compressData( + new TextEncoder().encode( + serializeAsJSON(elements, appState, files, "database"), + ), + { encryptionKey }, + ); + + try { + const filesMap = new Map<FileId, BinaryFileData>(); + for (const element of elements) { + if (isInitializedImageElement(element) && files[element.fileId]) { + filesMap.set(element.fileId, files[element.fileId]); + } + } + + const filesToUpload = await encodeFilesForUpload({ + files: filesMap, + encryptionKey, + maxBytes: FILE_UPLOAD_MAX_BYTES, + }); + + const response = await fetch(BACKEND_V2_POST, { + method: "POST", + body: payload.buffer, + }); + const json = await response.json(); + if (json.id) { + const url = new URL(window.location.href); + // We need to store the key (and less importantly the id) as hash instead + // of queryParam in order to never send it to the server + url.hash = `json=${json.id},${encryptionKey}`; + const urlString = url.toString(); + + await saveFilesToFirebase({ + prefix: `/files/shareLinks/${json.id}`, + files: filesToUpload, + }); + + return { url: urlString, errorMessage: null }; + } else if (json.error_class === "RequestTooLargeError") { + return { + url: null, + errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"), + }; + } + + return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") }; + } catch (error: any) { + console.error(error); + + return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") }; + } +}; |
