diff options
Diffstat (limited to 'excalidraw-app/data')
| -rw-r--r-- | excalidraw-app/data/FileManager.ts | 273 | ||||
| -rw-r--r-- | excalidraw-app/data/LocalData.ts | 258 | ||||
| -rw-r--r-- | excalidraw-app/data/Locker.ts | 18 | ||||
| -rw-r--r-- | excalidraw-app/data/firebase.ts | 313 | ||||
| -rw-r--r-- | excalidraw-app/data/index.ts | 338 | ||||
| -rw-r--r-- | excalidraw-app/data/localStorage.ts | 99 | ||||
| -rw-r--r-- | excalidraw-app/data/tabSync.ts | 39 |
7 files changed, 1338 insertions, 0 deletions
diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts new file mode 100644 index 0000000..f5f4eac --- /dev/null +++ b/excalidraw-app/data/FileManager.ts @@ -0,0 +1,273 @@ +import { CaptureUpdateAction } from "@excalidraw/excalidraw"; +import { compressData } from "@excalidraw/excalidraw/data/encode"; +import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; +import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawImageElement, + FileId, + InitializedExcalidrawImageElement, +} from "@excalidraw/excalidraw/element/types"; +import { t } from "@excalidraw/excalidraw/i18n"; +import type { + BinaryFileData, + BinaryFileMetadata, + ExcalidrawImperativeAPI, + BinaryFiles, +} from "@excalidraw/excalidraw/types"; + +type FileVersion = Required<BinaryFileData>["version"]; + +export class FileManager { + /** files being fetched */ + private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>(); + private erroredFiles_fetch = new Map< + ExcalidrawImageElement["fileId"], + true + >(); + /** files being saved */ + private savingFiles = new Map< + ExcalidrawImageElement["fileId"], + FileVersion + >(); + /* files already saved to persistent storage */ + private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>(); + private erroredFiles_save = new Map< + ExcalidrawImageElement["fileId"], + FileVersion + >(); + + private _getFiles; + private _saveFiles; + + constructor({ + getFiles, + saveFiles, + }: { + getFiles: (fileIds: FileId[]) => Promise<{ + loadedFiles: BinaryFileData[]; + erroredFiles: Map<FileId, true>; + }>; + saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{ + savedFiles: Map<FileId, BinaryFileData>; + erroredFiles: Map<FileId, BinaryFileData>; + }>; + }) { + this._getFiles = getFiles; + this._saveFiles = saveFiles; + } + + /** + * returns whether file is saved/errored, or being processed + */ + isFileTracked = (id: FileId) => { + return ( + this.savedFiles.has(id) || + this.savingFiles.has(id) || + this.fetchingFiles.has(id) || + this.erroredFiles_fetch.has(id) || + this.erroredFiles_save.has(id) + ); + }; + + isFileSavedOrBeingSaved = (file: BinaryFileData) => { + const fileVersion = this.getFileVersion(file); + return ( + this.savedFiles.get(file.id) === fileVersion || + this.savingFiles.get(file.id) === fileVersion + ); + }; + + getFileVersion = (file: BinaryFileData) => { + return file.version ?? 1; + }; + + saveFiles = async ({ + elements, + files, + }: { + elements: readonly ExcalidrawElement[]; + files: BinaryFiles; + }) => { + const addedFiles: Map<FileId, BinaryFileData> = new Map(); + + for (const element of elements) { + const fileData = + isInitializedImageElement(element) && files[element.fileId]; + + if ( + fileData && + // NOTE if errored during save, won't retry due to this check + !this.isFileSavedOrBeingSaved(fileData) + ) { + addedFiles.set(element.fileId, files[element.fileId]); + this.savingFiles.set(element.fileId, this.getFileVersion(fileData)); + } + } + + try { + const { savedFiles, erroredFiles } = await this._saveFiles({ + addedFiles, + }); + + for (const [fileId, fileData] of savedFiles) { + this.savedFiles.set(fileId, this.getFileVersion(fileData)); + } + + for (const [fileId, fileData] of erroredFiles) { + this.erroredFiles_save.set(fileId, this.getFileVersion(fileData)); + } + + return { + savedFiles, + erroredFiles, + }; + } finally { + for (const [fileId] of addedFiles) { + this.savingFiles.delete(fileId); + } + } + }; + + getFiles = async ( + ids: FileId[], + ): Promise<{ + loadedFiles: BinaryFileData[]; + erroredFiles: Map<FileId, true>; + }> => { + if (!ids.length) { + return { + loadedFiles: [], + erroredFiles: new Map(), + }; + } + for (const id of ids) { + this.fetchingFiles.set(id, true); + } + + try { + const { loadedFiles, erroredFiles } = await this._getFiles(ids); + + for (const file of loadedFiles) { + this.savedFiles.set(file.id, this.getFileVersion(file)); + } + for (const [fileId] of erroredFiles) { + this.erroredFiles_fetch.set(fileId, true); + } + + return { loadedFiles, erroredFiles }; + } finally { + for (const id of ids) { + this.fetchingFiles.delete(id); + } + } + }; + + /** a file element prevents unload only if it's being saved regardless of + * its `status`. This ensures that elements who for any reason haven't + * beed set to `saved` status don't prevent unload in future sessions. + * Technically we should prevent unload when the origin client haven't + * yet saved the `status` update to storage, but that should be taken care + * of during regular beforeUnload unsaved files check. + */ + shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => { + return elements.some((element) => { + return ( + isInitializedImageElement(element) && + !element.isDeleted && + this.savingFiles.has(element.fileId) + ); + }); + }; + + /** + * helper to determine if image element status needs updating + */ + shouldUpdateImageElementStatus = ( + element: ExcalidrawElement, + ): element is InitializedExcalidrawImageElement => { + return ( + isInitializedImageElement(element) && + this.savedFiles.has(element.fileId) && + element.status === "pending" + ); + }; + + reset() { + this.fetchingFiles.clear(); + this.savingFiles.clear(); + this.savedFiles.clear(); + this.erroredFiles_fetch.clear(); + this.erroredFiles_save.clear(); + } +} + +export const encodeFilesForUpload = async ({ + files, + maxBytes, + encryptionKey, +}: { + files: Map<FileId, BinaryFileData>; + maxBytes: number; + encryptionKey: string; +}) => { + const processedFiles: { + id: FileId; + buffer: Uint8Array; + }[] = []; + + for (const [id, fileData] of files) { + const buffer = new TextEncoder().encode(fileData.dataURL); + + const encodedFile = await compressData<BinaryFileMetadata>(buffer, { + encryptionKey, + metadata: { + id, + mimeType: fileData.mimeType, + created: Date.now(), + lastRetrieved: Date.now(), + }, + }); + + if (buffer.byteLength > maxBytes) { + throw new Error( + t("errors.fileTooBig", { + maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`, + }), + ); + } + + processedFiles.push({ + id, + buffer: encodedFile, + }); + } + + return processedFiles; +}; + +export const updateStaleImageStatuses = (params: { + excalidrawAPI: ExcalidrawImperativeAPI; + erroredFiles: Map<FileId, true>; + elements: readonly ExcalidrawElement[]; +}) => { + if (!params.erroredFiles.size) { + return; + } + params.excalidrawAPI.updateScene({ + elements: params.excalidrawAPI + .getSceneElementsIncludingDeleted() + .map((element) => { + if ( + isInitializedImageElement(element) && + params.erroredFiles.has(element.fileId) + ) { + return newElementWith(element, { + status: "error", + }); + } + return element; + }), + captureUpdate: CaptureUpdateAction.NEVER, + }); +}; diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts new file mode 100644 index 0000000..3ab0ad9 --- /dev/null +++ b/excalidraw-app/data/LocalData.ts @@ -0,0 +1,258 @@ +/** + * This file deals with saving data state (appState, elements, images, ...) + * locally to the browser. + * + * Notes: + * + * - DataState refers to full state of the app: appState, elements, images, + * though some state is saved separately (collab username, library) for one + * reason or another. We also save different data to different storage + * (localStorage, indexedDB). + */ + +import { + createStore, + entries, + del, + getMany, + set, + setMany, + get, +} from "idb-keyval"; +import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState"; +import { + CANVAS_SEARCH_TAB, + DEFAULT_SIDEBAR, +} from "@excalidraw/excalidraw/constants"; +import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; +import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; +import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element"; +import type { + ExcalidrawElement, + FileId, +} from "@excalidraw/excalidraw/element/types"; +import type { + AppState, + BinaryFileData, + BinaryFiles, +} from "@excalidraw/excalidraw/types"; +import type { MaybePromise } from "@excalidraw/excalidraw/utility-types"; +import { debounce } from "@excalidraw/excalidraw/utils"; +import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; +import { FileManager } from "./FileManager"; +import { Locker } from "./Locker"; +import { updateBrowserStateVersion } from "./tabSync"; + +const filesStore = createStore("files-db", "files-store"); + +class LocalFileManager extends FileManager { + clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { + await entries(filesStore).then((entries) => { + for (const [id, imageData] of entries as [FileId, BinaryFileData][]) { + // if image is unused (not on canvas) & is older than 1 day, delete it + // from storage. We check `lastRetrieved` we care about the last time + // the image was used (loaded on canvas), not when it was initially + // created. + if ( + (!imageData.lastRetrieved || + Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) && + !opts.currentFileIds.includes(id as FileId) + ) { + del(id, filesStore); + } + } + }); + }; +} + +const saveDataStateToLocalStorage = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + try { + const _appState = clearAppStateForLocalStorage(appState); + + if ( + _appState.openSidebar?.name === DEFAULT_SIDEBAR.name && + _appState.openSidebar.tab === CANVAS_SEARCH_TAB + ) { + _appState.openSidebar = null; + } + + localStorage.setItem( + STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, + JSON.stringify(clearElementsForLocalStorage(elements)), + ); + localStorage.setItem( + STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, + JSON.stringify(_appState), + ); + updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); + } catch (error: any) { + // Unable to access window.localStorage + console.error(error); + } +}; + +type SavingLockTypes = "collaboration"; + +export class LocalData { + private static _save = debounce( + async ( + elements: readonly ExcalidrawElement[], + appState: AppState, + files: BinaryFiles, + onFilesSaved: () => void, + ) => { + saveDataStateToLocalStorage(elements, appState); + + await this.fileStorage.saveFiles({ + elements, + files, + }); + onFilesSaved(); + }, + SAVE_TO_LOCAL_STORAGE_TIMEOUT, + ); + + /** Saves DataState, including files. Bails if saving is paused */ + static save = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + files: BinaryFiles, + onFilesSaved: () => void, + ) => { + // we need to make the `isSavePaused` check synchronously (undebounced) + if (!this.isSavePaused()) { + this._save(elements, appState, files, onFilesSaved); + } + }; + + static flushSave = () => { + this._save.flush(); + }; + + private static locker = new Locker<SavingLockTypes>(); + + static pauseSave = (lockType: SavingLockTypes) => { + this.locker.lock(lockType); + }; + + static resumeSave = (lockType: SavingLockTypes) => { + this.locker.unlock(lockType); + }; + + static isSavePaused = () => { + return document.hidden || this.locker.isLocked(); + }; + + // --------------------------------------------------------------------------- + + static fileStorage = new LocalFileManager({ + getFiles(ids) { + return getMany(ids, filesStore).then( + async (filesData: (BinaryFileData | undefined)[]) => { + const loadedFiles: BinaryFileData[] = []; + const erroredFiles = new Map<FileId, true>(); + + const filesToSave: [FileId, BinaryFileData][] = []; + + filesData.forEach((data, index) => { + const id = ids[index]; + if (data) { + const _data: BinaryFileData = { + ...data, + lastRetrieved: Date.now(), + }; + filesToSave.push([id, _data]); + loadedFiles.push(_data); + } else { + erroredFiles.set(id, true); + } + }); + + try { + // save loaded files back to storage with updated `lastRetrieved` + setMany(filesToSave, filesStore); + } catch (error) { + console.warn(error); + } + + return { loadedFiles, erroredFiles }; + }, + ); + }, + async saveFiles({ addedFiles }) { + const savedFiles = new Map<FileId, BinaryFileData>(); + const erroredFiles = new Map<FileId, BinaryFileData>(); + + // before we use `storage` event synchronization, let's update the flag + // optimistically. Hopefully nothing fails, and an IDB read executed + // before an IDB write finishes will read the latest value. + updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES); + + await Promise.all( + [...addedFiles].map(async ([id, fileData]) => { + try { + await set(id, fileData, filesStore); + savedFiles.set(id, fileData); + } catch (error: any) { + console.error(error); + erroredFiles.set(id, fileData); + } + }), + ); + + return { savedFiles, erroredFiles }; + }, + }); +} +export class LibraryIndexedDBAdapter { + /** IndexedDB database and store name */ + private static idb_name = STORAGE_KEYS.IDB_LIBRARY; + /** library data store key */ + private static key = "libraryData"; + + private static store = createStore( + `${LibraryIndexedDBAdapter.idb_name}-db`, + `${LibraryIndexedDBAdapter.idb_name}-store`, + ); + + static async load() { + const IDBData = await get<LibraryPersistedData>( + LibraryIndexedDBAdapter.key, + LibraryIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: LibraryPersistedData): MaybePromise<void> { + return set( + LibraryIndexedDBAdapter.key, + data, + LibraryIndexedDBAdapter.store, + ); + } +} + +/** LS Adapter used only for migrating LS library data + * to indexedDB */ +export class LibraryLocalStorageMigrationAdapter { + static load() { + const LSData = localStorage.getItem( + STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY, + ); + if (LSData != null) { + const libraryItems: ImportedDataState["libraryItems"] = + JSON.parse(LSData); + if (libraryItems) { + return { libraryItems }; + } + } + return null; + } + static clear() { + localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); + } +} diff --git a/excalidraw-app/data/Locker.ts b/excalidraw-app/data/Locker.ts new file mode 100644 index 0000000..c99673e --- /dev/null +++ b/excalidraw-app/data/Locker.ts @@ -0,0 +1,18 @@ +export class Locker<T extends string> { + private locks = new Map<T, true>(); + + lock = (lockType: T) => { + this.locks.set(lockType, true); + }; + + /** @returns whether no locks remaining */ + unlock = (lockType: T) => { + this.locks.delete(lockType); + return !this.isLocked(); + }; + + /** @returns whether some (or specific) locks are present */ + isLocked(lockType?: T) { + return lockType ? this.locks.has(lockType) : !!this.locks.size; + } +} diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts new file mode 100644 index 0000000..a871b48 --- /dev/null +++ b/excalidraw-app/data/firebase.ts @@ -0,0 +1,313 @@ +import { reconcileElements } from "@excalidraw/excalidraw"; +import type { + ExcalidrawElement, + FileId, + OrderedExcalidrawElement, +} from "@excalidraw/excalidraw/element/types"; +import { getSceneVersion } from "@excalidraw/excalidraw/element"; +import type Portal from "../collab/Portal"; +import { restoreElements } from "@excalidraw/excalidraw/data/restore"; +import type { + AppState, + BinaryFileData, + BinaryFileMetadata, + DataURL, +} from "@excalidraw/excalidraw/types"; +import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; +import { decompressData } from "@excalidraw/excalidraw/data/encode"; +import { + encryptData, + decryptData, +} from "@excalidraw/excalidraw/data/encryption"; +import { MIME_TYPES } from "@excalidraw/excalidraw/constants"; +import type { SyncableExcalidrawElement } from "."; +import { getSyncableElements } from "."; +import { initializeApp } from "firebase/app"; +import { + getFirestore, + doc, + getDoc, + runTransaction, + Bytes, +} from "firebase/firestore"; +import { getStorage, ref, uploadBytes } from "firebase/storage"; +import type { Socket } from "socket.io-client"; +import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; + +// private +// ----------------------------------------------------------------------------- + +let FIREBASE_CONFIG: Record<string, any>; +try { + FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG); +} catch (error: any) { + console.warn( + `Error JSON parsing firebase config. Supplied value: ${ + import.meta.env.VITE_APP_FIREBASE_CONFIG + }`, + ); + FIREBASE_CONFIG = {}; +} + +let firebaseApp: ReturnType<typeof initializeApp> | null = null; +let firestore: ReturnType<typeof getFirestore> | null = null; +let firebaseStorage: ReturnType<typeof getStorage> | null = null; + +const _initializeFirebase = () => { + if (!firebaseApp) { + firebaseApp = initializeApp(FIREBASE_CONFIG); + } + return firebaseApp; +}; + +const _getFirestore = () => { + if (!firestore) { + firestore = getFirestore(_initializeFirebase()); + } + return firestore; +}; + +const _getStorage = () => { + if (!firebaseStorage) { + firebaseStorage = getStorage(_initializeFirebase()); + } + return firebaseStorage; +}; + +// ----------------------------------------------------------------------------- + +export const loadFirebaseStorage = async () => { + return _getStorage(); +}; + +type FirebaseStoredScene = { + sceneVersion: number; + iv: Bytes; + ciphertext: Bytes; +}; + +const encryptElements = async ( + key: string, + elements: readonly ExcalidrawElement[], +): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => { + const json = JSON.stringify(elements); + const encoded = new TextEncoder().encode(json); + const { encryptedBuffer, iv } = await encryptData(key, encoded); + + return { ciphertext: encryptedBuffer, iv }; +}; + +const decryptElements = async ( + data: FirebaseStoredScene, + roomKey: string, +): Promise<readonly ExcalidrawElement[]> => { + const ciphertext = data.ciphertext.toUint8Array(); + const iv = data.iv.toUint8Array(); + + const decrypted = await decryptData(iv, ciphertext, roomKey); + const decodedData = new TextDecoder("utf-8").decode( + new Uint8Array(decrypted), + ); + return JSON.parse(decodedData); +}; + +class FirebaseSceneVersionCache { + private static cache = new WeakMap<Socket, number>(); + static get = (socket: Socket) => { + return FirebaseSceneVersionCache.cache.get(socket); + }; + static set = ( + socket: Socket, + elements: readonly SyncableExcalidrawElement[], + ) => { + FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements)); + }; +} + +export const isSavedToFirebase = ( + portal: Portal, + elements: readonly ExcalidrawElement[], +): boolean => { + if (portal.socket && portal.roomId && portal.roomKey) { + const sceneVersion = getSceneVersion(elements); + + return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion; + } + // if no room exists, consider the room saved so that we don't unnecessarily + // prevent unload (there's nothing we could do at that point anyway) + return true; +}; + +export const saveFilesToFirebase = async ({ + prefix, + files, +}: { + prefix: string; + files: { id: FileId; buffer: Uint8Array }[]; +}) => { + const storage = await loadFirebaseStorage(); + + const erroredFiles: FileId[] = []; + const savedFiles: FileId[] = []; + + await Promise.all( + files.map(async ({ id, buffer }) => { + try { + const storageRef = ref(storage, `${prefix}/${id}`); + await uploadBytes(storageRef, buffer, { + cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`, + }); + savedFiles.push(id); + } catch (error: any) { + erroredFiles.push(id); + } + }), + ); + + return { savedFiles, erroredFiles }; +}; + +const createFirebaseSceneDocument = async ( + elements: readonly SyncableExcalidrawElement[], + roomKey: string, +) => { + const sceneVersion = getSceneVersion(elements); + const { ciphertext, iv } = await encryptElements(roomKey, elements); + return { + sceneVersion, + ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)), + iv: Bytes.fromUint8Array(iv), + } as FirebaseStoredScene; +}; + +export const saveToFirebase = async ( + portal: Portal, + elements: readonly SyncableExcalidrawElement[], + appState: AppState, +) => { + const { roomId, roomKey, socket } = portal; + if ( + // bail if no room exists as there's nothing we can do at this point + !roomId || + !roomKey || + !socket || + isSavedToFirebase(portal, elements) + ) { + return null; + } + + const firestore = _getFirestore(); + const docRef = doc(firestore, "scenes", roomId); + + const storedScene = await runTransaction(firestore, async (transaction) => { + const snapshot = await transaction.get(docRef); + + if (!snapshot.exists()) { + const storedScene = await createFirebaseSceneDocument(elements, roomKey); + + transaction.set(docRef, storedScene); + + return storedScene; + } + + const prevStoredScene = snapshot.data() as FirebaseStoredScene; + const prevStoredElements = getSyncableElements( + restoreElements(await decryptElements(prevStoredScene, roomKey), null), + ); + const reconciledElements = getSyncableElements( + reconcileElements( + elements, + prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[], + appState, + ), + ); + + const storedScene = await createFirebaseSceneDocument( + reconciledElements, + roomKey, + ); + + transaction.update(docRef, storedScene); + + // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime + return storedScene; + }); + + const storedElements = getSyncableElements( + restoreElements(await decryptElements(storedScene, roomKey), null), + ); + + FirebaseSceneVersionCache.set(socket, storedElements); + + return storedElements; +}; + +export const loadFromFirebase = async ( + roomId: string, + roomKey: string, + socket: Socket | null, +): Promise<readonly SyncableExcalidrawElement[] | null> => { + const firestore = _getFirestore(); + const docRef = doc(firestore, "scenes", roomId); + const docSnap = await getDoc(docRef); + if (!docSnap.exists()) { + return null; + } + const storedScene = docSnap.data() as FirebaseStoredScene; + const elements = getSyncableElements( + restoreElements(await decryptElements(storedScene, roomKey), null), + ); + + if (socket) { + FirebaseSceneVersionCache.set(socket, elements); + } + + return elements; +}; + +export const loadFilesFromFirebase = async ( + prefix: string, + decryptionKey: string, + filesIds: readonly FileId[], +) => { + const loadedFiles: BinaryFileData[] = []; + const erroredFiles = new Map<FileId, true>(); + + await Promise.all( + [...new Set(filesIds)].map(async (id) => { + try { + const url = `https://firebasestorage.googleapis.com/v0/b/${ + FIREBASE_CONFIG.storageBucket + }/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`; + const response = await fetch(`${url}?alt=media`); + if (response.status < 400) { + const arrayBuffer = await response.arrayBuffer(); + + const { data, metadata } = await decompressData<BinaryFileMetadata>( + new Uint8Array(arrayBuffer), + { + decryptionKey, + }, + ); + + const dataURL = new TextDecoder().decode(data) as DataURL; + + loadedFiles.push({ + mimeType: metadata.mimeType || MIME_TYPES.binary, + id, + dataURL, + created: metadata?.created || Date.now(), + lastRetrieved: metadata?.created || Date.now(), + }); + } else { + erroredFiles.set(id, true); + } + } catch (error: any) { + erroredFiles.set(id, true); + console.error(error); + } + }), + ); + + return { loadedFiles, erroredFiles }; +}; 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") }; + } +}; diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts new file mode 100644 index 0000000..640dfdb --- /dev/null +++ b/excalidraw-app/data/localStorage.ts @@ -0,0 +1,99 @@ +import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import type { AppState } from "@excalidraw/excalidraw/types"; +import { + clearAppStateForLocalStorage, + getDefaultAppState, +} from "@excalidraw/excalidraw/appState"; +import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element"; +import { STORAGE_KEYS } from "../app_constants"; + +export const saveUsernameToLocalStorage = (username: string) => { + try { + localStorage.setItem( + STORAGE_KEYS.LOCAL_STORAGE_COLLAB, + JSON.stringify({ username }), + ); + } catch (error: any) { + // Unable to access window.localStorage + console.error(error); + } +}; + +export const importUsernameFromLocalStorage = (): string | null => { + try { + const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); + if (data) { + return JSON.parse(data).username; + } + } catch (error: any) { + // Unable to access localStorage + console.error(error); + } + + return null; +}; + +export const importFromLocalStorage = () => { + let savedElements = null; + let savedState = null; + + try { + savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); + savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); + } catch (error: any) { + // Unable to access localStorage + console.error(error); + } + + let elements: ExcalidrawElement[] = []; + if (savedElements) { + try { + elements = clearElementsForLocalStorage(JSON.parse(savedElements)); + } catch (error: any) { + console.error(error); + // Do nothing because elements array is already empty + } + } + + let appState = null; + if (savedState) { + try { + appState = { + ...getDefaultAppState(), + ...clearAppStateForLocalStorage( + JSON.parse(savedState) as Partial<AppState>, + ), + }; + } catch (error: any) { + console.error(error); + // Do nothing because appState is already null + } + } + return { elements, appState }; +}; + +export const getElementsStorageSize = () => { + try { + const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); + const elementsSize = elements?.length || 0; + return elementsSize; + } catch (error: any) { + console.error(error); + return 0; + } +}; + +export const getTotalStorageSize = () => { + try { + const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); + const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); + + const appStateSize = appState?.length || 0; + const collabSize = collab?.length || 0; + + return appStateSize + collabSize + getElementsStorageSize(); + } catch (error: any) { + console.error(error); + return 0; + } +}; diff --git a/excalidraw-app/data/tabSync.ts b/excalidraw-app/data/tabSync.ts new file mode 100644 index 0000000..0617a55 --- /dev/null +++ b/excalidraw-app/data/tabSync.ts @@ -0,0 +1,39 @@ +import { STORAGE_KEYS } from "../app_constants"; + +// in-memory state (this tab's current state) versions. Currently just +// timestamps of the last time the state was saved to browser storage. +const LOCAL_STATE_VERSIONS = { + [STORAGE_KEYS.VERSION_DATA_STATE]: -1, + [STORAGE_KEYS.VERSION_FILES]: -1, +}; + +type BrowserStateTypes = keyof typeof LOCAL_STATE_VERSIONS; + +export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => { + const storageTimestamp = JSON.parse(localStorage.getItem(type) || "-1"); + return storageTimestamp > LOCAL_STATE_VERSIONS[type]; +}; + +export const updateBrowserStateVersion = (type: BrowserStateTypes) => { + const timestamp = Date.now(); + try { + localStorage.setItem(type, JSON.stringify(timestamp)); + LOCAL_STATE_VERSIONS[type] = timestamp; + } catch (error) { + console.error("error while updating browser state verison", error); + } +}; + +export const resetBrowserStateVersions = () => { + try { + for (const key of Object.keys( + LOCAL_STATE_VERSIONS, + ) as BrowserStateTypes[]) { + const timestamp = -1; + localStorage.setItem(key, JSON.stringify(timestamp)); + LOCAL_STATE_VERSIONS[key] = timestamp; + } + } catch (error) { + console.error("error while resetting browser state verison", error); + } +}; |
