summaryrefslogtreecommitdiffstats
path: root/excalidraw-app/data/firebase.ts
diff options
context:
space:
mode:
Diffstat (limited to 'excalidraw-app/data/firebase.ts')
-rw-r--r--excalidraw-app/data/firebase.ts313
1 files changed, 313 insertions, 0 deletions
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 };
+};