Browse Source

Persistent rooms via Firebase (#2188)

* Periodically back up collaborative rooms in firebase

* Responses to code review

* comments from code review, new firebase credentials
Pete Hunt 4 years ago
parent
commit
d0985fe67a

+ 1 - 0
.env

@@ -2,3 +2,4 @@ REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
 REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
 REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
 REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.com
+REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'

+ 5 - 0
firebase-project/.firebaserc

@@ -0,0 +1,5 @@
+{
+  "projects": {
+    "default": "excalidraw-room-persistence"
+  }
+}

+ 66 - 0
firebase-project/.gitignore

@@ -0,0 +1,66 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+firebase-debug.log*
+firebase-debug.*.log*
+
+# Firebase cache
+.firebase/
+
+# Firebase config
+
+# Uncomment this if you'd like others to create their own Firebase project.
+# For a team working on the same Firebase project(s), it is recommended to leave
+# it commented so all members can deploy to the same project(s) in .firebaserc.
+# .firebaserc
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env

+ 6 - 0
firebase-project/firebase.json

@@ -0,0 +1,6 @@
+{
+  "firestore": {
+    "rules": "firestore.rules",
+    "indexes": "firestore.indexes.json"
+  }
+}

+ 4 - 0
firebase-project/firestore.indexes.json

@@ -0,0 +1,4 @@
+{
+  "indexes": [],
+  "fieldOverrides": []
+}

+ 10 - 0
firebase-project/firestore.rules

@@ -0,0 +1,10 @@
+rules_version = '2';
+service cloud.firestore {
+  match /databases/{database}/documents {
+    match /{document=**} {
+      allow get, write: if true;
+      // never set this to true, otherwise anyone can delete anyone else's drawing.
+      allow list: if false;
+    }
+  }
+}

File diff suppressed because it is too large
+ 943 - 202
package-lock.json


+ 2 - 0
package.json

@@ -29,6 +29,7 @@
     "@types/react-dom": "16.9.8",
     "@types/socket.io-client": "1.4.33",
     "browser-nativefs": "0.10.3",
+    "firebase": "7.21.1",
     "i18next-browser-languagedetector": "6.0.1",
     "lodash.throttle": "4.1.1",
     "nanoid": "2.1.11",
@@ -49,6 +50,7 @@
     "eslint": "6.8.0",
     "eslint-config-prettier": "6.12.0",
     "eslint-plugin-prettier": "3.1.4",
+    "firebase-tools": "8.11.2",
     "husky": "4.3.0",
     "jest-canvas-mock": "2.2.0",
     "lint-staged": "10.4.0",

+ 56 - 14
src/components/App.tsx

@@ -17,7 +17,7 @@ import {
   getPerfectElementSize,
   getNormalizedDimensions,
   getElementMap,
-  getDrawingVersion,
+  getSceneVersion,
   getSyncableElements,
   newLinearElement,
   transformElements,
@@ -176,6 +176,7 @@ import {
 import { MaybeTransformHandleType } from "../element/transformHandles";
 import { renderSpreadsheet } from "../charts";
 import { isValidLibrary } from "../data/json";
+import { loadFromFirebase, saveToFirebase } from "../data/firebase";
 
 /**
  * @param func handler taking at most single parameter (event).
@@ -468,6 +469,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       return false;
     }
 
+    const roomId = roomMatch[1];
+
     let collabForceLoadFlag;
     try {
       collabForceLoadFlag = localStorage?.getItem(
@@ -485,7 +488,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         );
         // if loading same room as the one previously unloaded within 15sec
         //  force reload without prompting
-        if (previousRoom === roomMatch[1] && Date.now() - timestamp < 15000) {
+        if (previousRoom === roomId && Date.now() - timestamp < 15000) {
           return true;
         }
       } catch {}
@@ -902,7 +905,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     }
 
     if (
-      getDrawingVersion(this.scene.getElementsIncludingDeleted()) >
+      getSceneVersion(this.scene.getElementsIncludingDeleted()) >
       this.lastBroadcastedOrReceivedSceneVersion
     ) {
       this.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
@@ -1210,6 +1213,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     }
     const roomMatch = getCollaborationLinkData(window.location.href);
     if (roomMatch) {
+      const roomId = roomMatch[1];
+      const roomSecret = roomMatch[2];
+
       const initialize = () => {
         this.portal.socketInitialized = true;
         clearTimeout(initializationTimer);
@@ -1226,12 +1232,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
       const updateScene = (
         decryptedData: SocketUpdateDataSource[SCENE.INIT | SCENE.UPDATE],
-        { init = false }: { init?: boolean } = {},
+        {
+          init = false,
+          initFromSnapshot = false,
+        }: { init?: boolean; initFromSnapshot?: boolean } = {},
       ) => {
         const { elements: remoteElements } = decryptedData.payload;
 
         if (init) {
           history.resumeRecording();
+        }
+
+        if (init || initFromSnapshot) {
           this.setState({
             ...this.state,
             ...calculateScrollCenter(
@@ -1311,7 +1323,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           // we just received!
           // Note: this needs to be set before replaceAllElements as it
           // syncronously calls render.
-          this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
+          this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(
             newElements,
           );
 
@@ -1323,7 +1335,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
         // right now we think this is the right tradeoff.
         history.clear();
-        if (!this.portal.socketInitialized) {
+        if (!this.portal.socketInitialized && !initFromSnapshot) {
           initialize();
         }
       };
@@ -1332,11 +1344,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         /* webpackChunkName: "socketIoClient" */ "socket.io-client"
       );
 
-      this.portal.open(
-        socketIOClient(SOCKET_SERVER),
-        roomMatch[1],
-        roomMatch[2],
-      );
+      this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomSecret);
 
       // All socket listeners are moving to Portal
       this.portal.socket!.on(
@@ -1406,6 +1414,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         isCollaborating: true,
         isLoading: opts.showLoadingState ? true : this.state.isLoading,
       });
+
+      try {
+        const elements = await loadFromFirebase(roomId, roomSecret);
+        if (elements) {
+          updateScene(
+            { type: "SCENE_UPDATE", payload: { elements } },
+            { initFromSnapshot: true },
+          );
+        }
+      } catch (e) {
+        // log the error and move on. other peers will sync us the scene.
+        console.error(e);
+      }
     }
   };
 
@@ -1450,7 +1471,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   };
 
   // maybe should move to Portal
-  broadcastScene = (sceneType: SCENE.INIT | SCENE.UPDATE, syncAll: boolean) => {
+  broadcastScene = async (
+    sceneType: SCENE.INIT | SCENE.UPDATE,
+    syncAll: boolean,
+  ) => {
     if (sceneType === SCENE.INIT && !syncAll) {
       throw new Error("syncAll must be true when sending SCENE.INIT");
     }
@@ -1479,7 +1503,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     };
     this.lastBroadcastedOrReceivedSceneVersion = Math.max(
       this.lastBroadcastedOrReceivedSceneVersion,
-      getDrawingVersion(this.scene.getElementsIncludingDeleted()),
+      getSceneVersion(this.scene.getElementsIncludingDeleted()),
     );
     for (const syncableElement of syncableElements) {
       this.broadcastedElementVersions.set(
@@ -1487,7 +1511,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         syncableElement.version,
       );
     }
-    return this.portal._broadcastSocketData(data as SocketUpdateData);
+
+    const broadcastPromise = this.portal._broadcastSocketData(
+      data as SocketUpdateData,
+    );
+
+    if (syncAll && this.portal.roomID && this.portal.roomKey) {
+      await Promise.all([
+        broadcastPromise,
+        saveToFirebase(
+          this.portal.roomID,
+          this.portal.roomKey,
+          syncableElements,
+        ).catch((e) => {
+          console.error(e);
+        }),
+      ]);
+    } else {
+      await broadcastPromise;
+    }
   };
 
   private onSceneUpdated = () => {

+ 127 - 0
src/data/firebase.ts

@@ -0,0 +1,127 @@
+import { createIV, getImportedKey } from "./index";
+import { ExcalidrawElement } from "../element/types";
+import { getSceneVersion } from "../element";
+
+let firebasePromise: Promise<typeof import("firebase/app")> | null = null;
+
+async function loadFirebase() {
+  const firebase = await import("firebase/app");
+  await import("firebase/firestore");
+
+  const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
+  firebase.initializeApp(firebaseConfig);
+
+  return firebase;
+}
+
+async function getFirebase(): Promise<typeof import("firebase/app")> {
+  if (!firebasePromise) {
+    firebasePromise = loadFirebase();
+  }
+  const firebase = await firebasePromise!;
+  return firebase;
+}
+
+interface FirebaseStoredScene {
+  sceneVersion: number;
+  iv: firebase.firestore.Blob;
+  ciphertext: firebase.firestore.Blob;
+}
+
+async function encryptElements(
+  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 };
+}
+
+async function decryptElements(
+  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);
+}
+
+export async function saveToFirebase(
+  roomId: string,
+  roomSecret: string,
+  elements: readonly ExcalidrawElement[],
+) {
+  const firebase = await getFirebase();
+  const sceneVersion = getSceneVersion(elements);
+  const { ciphertext, iv } = await encryptElements(roomSecret, 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;
+  });
+
+  return didUpdate;
+}
+
+export async function loadFromFirebase(
+  roomId: string,
+  roomSecret: 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();
+  const plaintext = await decryptElements(roomSecret, iv, ciphertext);
+  return plaintext;
+}

+ 2 - 2
src/data/index.ts

@@ -89,7 +89,7 @@ const generateEncryptionKey = async () => {
   return (await window.crypto.subtle.exportKey("jwk", key)).k;
 };
 
-const createIV = () => {
+export const createIV = () => {
   const arr = new Uint8Array(12);
   return window.crypto.getRandomValues(arr);
 };
@@ -108,7 +108,7 @@ export const generateCollaborationLink = async () => {
   return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
 };
 
-const getImportedKey = (key: string, usage: KeyUsage) =>
+export const getImportedKey = (key: string, usage: KeyUsage) =>
   window.crypto.subtle.importKey(
     "jwk",
     {

+ 1 - 1
src/data/restore.ts

@@ -31,7 +31,7 @@ const restoreElementWithProperties = <T extends ExcalidrawElement>(
 ): T => {
   const base: Pick<T, keyof ExcalidrawElement> = {
     type: element.type,
-    // all elements must have version > 0 so getDrawingVersion() will pick up
+    // all elements must have version > 0 so getSceneVersion() will pick up
     //  newly added elements
     version: element.version || 1,
     versionNonce: element.versionNonce ?? 0,

+ 1 - 1
src/element/index.ts

@@ -74,7 +74,7 @@ export const getElementMap = (elements: readonly ExcalidrawElement[]) =>
     {},
   );
 
-export const getDrawingVersion = (elements: readonly ExcalidrawElement[]) =>
+export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
   elements.reduce((acc, el) => acc + el.version, 0);
 
 export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>

+ 1 - 0
src/global.d.ts

@@ -20,6 +20,7 @@ declare namespace NodeJS {
     readonly REACT_APP_BACKEND_V2_GET_URL: string;
     readonly REACT_APP_BACKEND_V2_POST_URL: string;
     readonly REACT_APP_SOCKET_SERVER_URL: string;
+    readonly REACT_APP_FIREBASE_CONFIG: string;
   }
 }
 

Some files were not shown because too many files changed in this diff