mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	Factor out collaboration code (#2313)
Co-authored-by: Lipis <lipiridis@gmail.com> Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
		
							
								
								
									
										164
									
								
								src/excalidraw-app/data/firebase.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								src/excalidraw-app/data/firebase.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,164 @@
 | 
			
		||||
import { getImportedKey } from "../data";
 | 
			
		||||
import { createIV } from "./index";
 | 
			
		||||
import { ExcalidrawElement } from "../../element/types";
 | 
			
		||||
import { getSceneVersion } from "../../element";
 | 
			
		||||
import Portal from "../collab/Portal";
 | 
			
		||||
import { restoreElements } from "../../data/restore";
 | 
			
		||||
 | 
			
		||||
let firebasePromise: Promise<
 | 
			
		||||
  typeof import("firebase/app").default
 | 
			
		||||
> | null = null;
 | 
			
		||||
 | 
			
		||||
const loadFirebase = async () => {
 | 
			
		||||
  const firebase = (
 | 
			
		||||
    await import(/* webpackChunkName: "firebase" */ "firebase/app")
 | 
			
		||||
  ).default;
 | 
			
		||||
  await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
 | 
			
		||||
 | 
			
		||||
  const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
 | 
			
		||||
  firebase.initializeApp(firebaseConfig);
 | 
			
		||||
 | 
			
		||||
  return firebase;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getFirebase = async (): Promise<
 | 
			
		||||
  typeof import("firebase/app").default
 | 
			
		||||
> => {
 | 
			
		||||
  if (!firebasePromise) {
 | 
			
		||||
    firebasePromise = loadFirebase();
 | 
			
		||||
  }
 | 
			
		||||
  return await firebasePromise!;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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 importedKey = await getImportedKey(key, "encrypt");
 | 
			
		||||
  const iv = createIV();
 | 
			
		||||
  const json = JSON.stringify(elements);
 | 
			
		||||
  const encoded = new TextEncoder().encode(json);
 | 
			
		||||
  const ciphertext = await window.crypto.subtle.encrypt(
 | 
			
		||||
    {
 | 
			
		||||
      name: "AES-GCM",
 | 
			
		||||
      iv,
 | 
			
		||||
    },
 | 
			
		||||
    importedKey,
 | 
			
		||||
    encoded,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return { ciphertext, iv };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const decryptElements = async (
 | 
			
		||||
  key: string,
 | 
			
		||||
  iv: Uint8Array,
 | 
			
		||||
  ciphertext: ArrayBuffer,
 | 
			
		||||
): Promise<readonly ExcalidrawElement[]> => {
 | 
			
		||||
  const importedKey = await getImportedKey(key, "decrypt");
 | 
			
		||||
  const decrypted = await window.crypto.subtle.decrypt(
 | 
			
		||||
    {
 | 
			
		||||
      name: "AES-GCM",
 | 
			
		||||
      iv,
 | 
			
		||||
    },
 | 
			
		||||
    importedKey,
 | 
			
		||||
    ciphertext,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const decodedData = new TextDecoder("utf-8").decode(
 | 
			
		||||
    new Uint8Array(decrypted) as any,
 | 
			
		||||
  );
 | 
			
		||||
  return JSON.parse(decodedData);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const firebaseSceneVersionCache = new WeakMap<SocketIOClient.Socket, number>();
 | 
			
		||||
 | 
			
		||||
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 saveToFirebase = async (
 | 
			
		||||
  portal: Portal,
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
) => {
 | 
			
		||||
  const { roomId, roomKey, socket } = portal;
 | 
			
		||||
  if (
 | 
			
		||||
    // if no room exists, consider the room saved because there's nothing we can
 | 
			
		||||
    // do at this point
 | 
			
		||||
    !roomId ||
 | 
			
		||||
    !roomKey ||
 | 
			
		||||
    !socket ||
 | 
			
		||||
    isSavedToFirebase(portal, elements)
 | 
			
		||||
  ) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const firebase = await getFirebase();
 | 
			
		||||
  const sceneVersion = getSceneVersion(elements);
 | 
			
		||||
  const { ciphertext, iv } = await encryptElements(roomKey, elements);
 | 
			
		||||
 | 
			
		||||
  const nextDocData = {
 | 
			
		||||
    sceneVersion,
 | 
			
		||||
    ciphertext: firebase.firestore.Blob.fromUint8Array(
 | 
			
		||||
      new Uint8Array(ciphertext),
 | 
			
		||||
    ),
 | 
			
		||||
    iv: firebase.firestore.Blob.fromUint8Array(iv),
 | 
			
		||||
  } as FirebaseStoredScene;
 | 
			
		||||
 | 
			
		||||
  const db = firebase.firestore();
 | 
			
		||||
  const docRef = db.collection("scenes").doc(roomId);
 | 
			
		||||
  const didUpdate = await db.runTransaction(async (transaction) => {
 | 
			
		||||
    const doc = await transaction.get(docRef);
 | 
			
		||||
    if (!doc.exists) {
 | 
			
		||||
      transaction.set(docRef, nextDocData);
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevDocData = doc.data() as FirebaseStoredScene;
 | 
			
		||||
    if (prevDocData.sceneVersion >= nextDocData.sceneVersion) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    transaction.update(docRef, nextDocData);
 | 
			
		||||
    return true;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (didUpdate) {
 | 
			
		||||
    firebaseSceneVersionCache.set(socket, sceneVersion);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return didUpdate;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const loadFromFirebase = async (
 | 
			
		||||
  roomId: string,
 | 
			
		||||
  roomKey: string,
 | 
			
		||||
): Promise<readonly ExcalidrawElement[] | null> => {
 | 
			
		||||
  const firebase = await getFirebase();
 | 
			
		||||
  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 ciphertext = storedScene.ciphertext.toUint8Array();
 | 
			
		||||
  const iv = storedScene.iv.toUint8Array();
 | 
			
		||||
  return restoreElements(await decryptElements(roomKey, iv, ciphertext));
 | 
			
		||||
};
 | 
			
		||||
		Reference in New Issue
	
	Block a user