123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- import {
- ExcalidrawElement,
- FileId,
- } from "../../packages/excalidraw/element/types";
- import { getSceneVersion } from "../../packages/excalidraw/element";
- import Portal from "../collab/Portal";
- import { restoreElements } from "../../packages/excalidraw/data/restore";
- import {
- 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 { reconcileElements } from "../collab/reconciliation";
- import { getSyncableElements, SyncableExcalidrawElement } from ".";
- import { ResolutionType } from "../../packages/excalidraw/utility-types";
- // 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 firebasePromise: Promise<typeof import("firebase/app").default> | null =
- null;
- let firestorePromise: Promise<any> | null | true = null;
- let firebaseStoragePromise: Promise<any> | 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<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<SocketIOClient.Socket, number>();
- static get = (socket: SocketIOClient.Socket) => {
- return FirebaseSceneVersionCache.cache.get(socket);
- };
- static set = (
- socket: SocketIOClient.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<FileId, true>();
- const savedFiles = new Map<FileId, true>();
- 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<typeof loadFirestore>,
- 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 false;
- }
- const firebase = await loadFirestore();
- const firestore = firebase.firestore();
- const docRef = firestore.collection("scenes").doc(roomId);
- const savedData = await firestore.runTransaction(async (transaction) => {
- const snapshot = await transaction.get(docRef);
- if (!snapshot.exists) {
- const sceneDocument = await createFirebaseSceneDocument(
- firebase,
- elements,
- roomKey,
- );
- transaction.set(docRef, sceneDocument);
- return {
- elements,
- reconciledElements: null,
- };
- }
- const prevDocData = snapshot.data() as FirebaseStoredScene;
- const prevElements = getSyncableElements(
- await decryptElements(prevDocData, roomKey),
- );
- const reconciledElements = getSyncableElements(
- reconcileElements(elements, prevElements, appState),
- );
- const sceneDocument = await createFirebaseSceneDocument(
- firebase,
- reconciledElements,
- roomKey,
- );
- transaction.update(docRef, sceneDocument);
- return {
- elements,
- reconciledElements,
- };
- });
- FirebaseSceneVersionCache.set(socket, savedData.elements);
- return { reconciledElements: savedData.reconciledElements };
- };
- export const loadFromFirebase = async (
- roomId: string,
- roomKey: string,
- socket: SocketIOClient.Socket | null,
- ): Promise<readonly ExcalidrawElement[] | null> => {
- 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(
- await decryptElements(storedScene, roomKey),
- );
- if (socket) {
- FirebaseSceneVersionCache.set(socket, elements);
- }
- return restoreElements(elements, null);
- };
- 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 };
- };
|