import { reconcileElements } from "../../packages/excalidraw"; import type { ExcalidrawElement, FileId, OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { getSceneVersion } from "../../packages/excalidraw/element"; import type Portal from "../collab/Portal"; import { restoreElements } from "../../packages/excalidraw/data/restore"; import type { AppState, BinaryFileData, BinaryFileMetadata, DataURL, } from "../../packages/excalidraw/types"; import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; import { decompressData } from "../../packages/excalidraw/data/encode"; import { encryptData, decryptData, } from "../../packages/excalidraw/data/encryption"; import { MIME_TYPES } from "../../packages/excalidraw/constants"; import type { SyncableExcalidrawElement } from "."; import { getSyncableElements } from "."; import type { ResolutionType } from "../../packages/excalidraw/utility-types"; import type { Socket } from "socket.io-client"; import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile"; // private // ----------------------------------------------------------------------------- let FIREBASE_CONFIG: Record; 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 firebasePromise: Promise | null = null; let firestorePromise: Promise | null | true = null; let firebaseStoragePromise: Promise | null | true = null; let isFirebaseInitialized = false; const _loadFirebase = async () => { const firebase = ( await import(/* webpackChunkName: "firebase" */ "firebase/app") ).default; if (!isFirebaseInitialized) { try { firebase.initializeApp(FIREBASE_CONFIG); } catch (error: any) { // trying initialize again throws. Usually this is harmless, and happens // mainly in dev (HMR) if (error.code === "app/duplicate-app") { console.warn(error.name, error.code); } else { throw error; } } isFirebaseInitialized = true; } return firebase; }; const _getFirebase = async (): Promise< typeof import("firebase/app").default > => { if (!firebasePromise) { firebasePromise = _loadFirebase(); } return firebasePromise; }; // ----------------------------------------------------------------------------- const loadFirestore = async () => { const firebase = await _getFirebase(); if (!firestorePromise) { firestorePromise = import( /* webpackChunkName: "firestore" */ "firebase/firestore" ); } if (firestorePromise !== true) { await firestorePromise; firestorePromise = true; } return firebase; }; export const loadFirebaseStorage = async () => { const firebase = await _getFirebase(); if (!firebaseStoragePromise) { firebaseStoragePromise = import( /* webpackChunkName: "storage" */ "firebase/storage" ); } if (firebaseStoragePromise !== true) { await firebaseStoragePromise; firebaseStoragePromise = true; } return firebase; }; interface FirebaseStoredScene { sceneVersion: number; iv: firebase.default.firestore.Blob; ciphertext: firebase.default.firestore.Blob; } 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 => { 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(); 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 firebase = await loadFirebaseStorage(); const erroredFiles = new Map(); const savedFiles = new Map(); await Promise.all( files.map(async ({ id, buffer }) => { try { await firebase .storage() .ref(`${prefix}/${id}`) .put( new Blob([buffer], { type: MIME_TYPES.binary, }), { cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`, }, ); savedFiles.set(id, true); } catch (error: any) { erroredFiles.set(id, true); } }), ); return { savedFiles, erroredFiles }; }; const createFirebaseSceneDocument = async ( firebase: ResolutionType, elements: readonly SyncableExcalidrawElement[], roomKey: string, ) => { const sceneVersion = getSceneVersion(elements); const { ciphertext, iv } = await encryptElements(roomKey, elements); return { sceneVersion, ciphertext: firebase.firestore.Blob.fromUint8Array( new Uint8Array(ciphertext), ), iv: firebase.firestore.Blob.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 firebase = await loadFirestore(); const firestore = firebase.firestore(); const docRef = firestore.collection("scenes").doc(roomId); const storedScene = await firestore.runTransaction(async (transaction) => { const snapshot = await transaction.get(docRef); if (!snapshot.exists) { const storedScene = await createFirebaseSceneDocument( firebase, 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( firebase, 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 => { const firebase = await loadFirestore(); const db = firebase.firestore(); const docRef = db.collection("scenes").doc(roomId); const doc = await docRef.get(); if (!doc.exists) { return null; } const storedScene = doc.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(); 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( 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 }; };