Преглед на файлове

Persistent rooms via Firebase (#2188)

* Periodically back up collaborative rooms in firebase

* Responses to code review

* comments from code review, new firebase credentials
vanilla_orig
Pete Hunt преди 4 години
родител
ревизия
d0985fe67a
No account linked to committer's email address

+ 1
- 0
.env Целия файл

@@ -2,3 +2,4 @@ REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
2 2
 REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
3 3
 REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
4 4
 REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.com
5
+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 @@
1
+{
2
+  "projects": {
3
+    "default": "excalidraw-room-persistence"
4
+  }
5
+}

+ 66
- 0
firebase-project/.gitignore Целия файл

@@ -0,0 +1,66 @@
1
+# Logs
2
+logs
3
+*.log
4
+npm-debug.log*
5
+yarn-debug.log*
6
+yarn-error.log*
7
+firebase-debug.log*
8
+firebase-debug.*.log*
9
+
10
+# Firebase cache
11
+.firebase/
12
+
13
+# Firebase config
14
+
15
+# Uncomment this if you'd like others to create their own Firebase project.
16
+# For a team working on the same Firebase project(s), it is recommended to leave
17
+# it commented so all members can deploy to the same project(s) in .firebaserc.
18
+# .firebaserc
19
+
20
+# Runtime data
21
+pids
22
+*.pid
23
+*.seed
24
+*.pid.lock
25
+
26
+# Directory for instrumented libs generated by jscoverage/JSCover
27
+lib-cov
28
+
29
+# Coverage directory used by tools like istanbul
30
+coverage
31
+
32
+# nyc test coverage
33
+.nyc_output
34
+
35
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
36
+.grunt
37
+
38
+# Bower dependency directory (https://bower.io/)
39
+bower_components
40
+
41
+# node-waf configuration
42
+.lock-wscript
43
+
44
+# Compiled binary addons (http://nodejs.org/api/addons.html)
45
+build/Release
46
+
47
+# Dependency directories
48
+node_modules/
49
+
50
+# Optional npm cache directory
51
+.npm
52
+
53
+# Optional eslint cache
54
+.eslintcache
55
+
56
+# Optional REPL history
57
+.node_repl_history
58
+
59
+# Output of 'npm pack'
60
+*.tgz
61
+
62
+# Yarn Integrity file
63
+.yarn-integrity
64
+
65
+# dotenv environment variables file
66
+.env

+ 6
- 0
firebase-project/firebase.json Целия файл

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

+ 4
- 0
firebase-project/firestore.indexes.json Целия файл

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

+ 10
- 0
firebase-project/firestore.rules Целия файл

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

+ 4437
- 300
package-lock.json
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 2
- 0
package.json Целия файл

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

+ 56
- 14
src/components/App.tsx Целия файл

@@ -17,7 +17,7 @@ import {
17 17
   getPerfectElementSize,
18 18
   getNormalizedDimensions,
19 19
   getElementMap,
20
-  getDrawingVersion,
20
+  getSceneVersion,
21 21
   getSyncableElements,
22 22
   newLinearElement,
23 23
   transformElements,
@@ -176,6 +176,7 @@ import {
176 176
 import { MaybeTransformHandleType } from "../element/transformHandles";
177 177
 import { renderSpreadsheet } from "../charts";
178 178
 import { isValidLibrary } from "../data/json";
179
+import { loadFromFirebase, saveToFirebase } from "../data/firebase";
179 180
 
180 181
 /**
181 182
  * @param func handler taking at most single parameter (event).
@@ -468,6 +469,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
468 469
       return false;
469 470
     }
470 471
 
472
+    const roomId = roomMatch[1];
473
+
471 474
     let collabForceLoadFlag;
472 475
     try {
473 476
       collabForceLoadFlag = localStorage?.getItem(
@@ -485,7 +488,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
485 488
         );
486 489
         // if loading same room as the one previously unloaded within 15sec
487 490
         //  force reload without prompting
488
-        if (previousRoom === roomMatch[1] && Date.now() - timestamp < 15000) {
491
+        if (previousRoom === roomId && Date.now() - timestamp < 15000) {
489 492
           return true;
490 493
         }
491 494
       } catch {}
@@ -902,7 +905,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
902 905
     }
903 906
 
904 907
     if (
905
-      getDrawingVersion(this.scene.getElementsIncludingDeleted()) >
908
+      getSceneVersion(this.scene.getElementsIncludingDeleted()) >
906 909
       this.lastBroadcastedOrReceivedSceneVersion
907 910
     ) {
908 911
       this.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
@@ -1210,6 +1213,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1210 1213
     }
1211 1214
     const roomMatch = getCollaborationLinkData(window.location.href);
1212 1215
     if (roomMatch) {
1216
+      const roomId = roomMatch[1];
1217
+      const roomSecret = roomMatch[2];
1218
+
1213 1219
       const initialize = () => {
1214 1220
         this.portal.socketInitialized = true;
1215 1221
         clearTimeout(initializationTimer);
@@ -1226,12 +1232,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1226 1232
 
1227 1233
       const updateScene = (
1228 1234
         decryptedData: SocketUpdateDataSource[SCENE.INIT | SCENE.UPDATE],
1229
-        { init = false }: { init?: boolean } = {},
1235
+        {
1236
+          init = false,
1237
+          initFromSnapshot = false,
1238
+        }: { init?: boolean; initFromSnapshot?: boolean } = {},
1230 1239
       ) => {
1231 1240
         const { elements: remoteElements } = decryptedData.payload;
1232 1241
 
1233 1242
         if (init) {
1234 1243
           history.resumeRecording();
1244
+        }
1245
+
1246
+        if (init || initFromSnapshot) {
1235 1247
           this.setState({
1236 1248
             ...this.state,
1237 1249
             ...calculateScrollCenter(
@@ -1311,7 +1323,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1311 1323
           // we just received!
1312 1324
           // Note: this needs to be set before replaceAllElements as it
1313 1325
           // syncronously calls render.
1314
-          this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
1326
+          this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(
1315 1327
             newElements,
1316 1328
           );
1317 1329
 
@@ -1323,7 +1335,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1323 1335
         // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
1324 1336
         // right now we think this is the right tradeoff.
1325 1337
         history.clear();
1326
-        if (!this.portal.socketInitialized) {
1338
+        if (!this.portal.socketInitialized && !initFromSnapshot) {
1327 1339
           initialize();
1328 1340
         }
1329 1341
       };
@@ -1332,11 +1344,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1332 1344
         /* webpackChunkName: "socketIoClient" */ "socket.io-client"
1333 1345
       );
1334 1346
 
1335
-      this.portal.open(
1336
-        socketIOClient(SOCKET_SERVER),
1337
-        roomMatch[1],
1338
-        roomMatch[2],
1339
-      );
1347
+      this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomSecret);
1340 1348
 
1341 1349
       // All socket listeners are moving to Portal
1342 1350
       this.portal.socket!.on(
@@ -1406,6 +1414,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1406 1414
         isCollaborating: true,
1407 1415
         isLoading: opts.showLoadingState ? true : this.state.isLoading,
1408 1416
       });
1417
+
1418
+      try {
1419
+        const elements = await loadFromFirebase(roomId, roomSecret);
1420
+        if (elements) {
1421
+          updateScene(
1422
+            { type: "SCENE_UPDATE", payload: { elements } },
1423
+            { initFromSnapshot: true },
1424
+          );
1425
+        }
1426
+      } catch (e) {
1427
+        // log the error and move on. other peers will sync us the scene.
1428
+        console.error(e);
1429
+      }
1409 1430
     }
1410 1431
   };
1411 1432
 
@@ -1450,7 +1471,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1450 1471
   };
1451 1472
 
1452 1473
   // maybe should move to Portal
1453
-  broadcastScene = (sceneType: SCENE.INIT | SCENE.UPDATE, syncAll: boolean) => {
1474
+  broadcastScene = async (
1475
+    sceneType: SCENE.INIT | SCENE.UPDATE,
1476
+    syncAll: boolean,
1477
+  ) => {
1454 1478
     if (sceneType === SCENE.INIT && !syncAll) {
1455 1479
       throw new Error("syncAll must be true when sending SCENE.INIT");
1456 1480
     }
@@ -1479,7 +1503,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1479 1503
     };
1480 1504
     this.lastBroadcastedOrReceivedSceneVersion = Math.max(
1481 1505
       this.lastBroadcastedOrReceivedSceneVersion,
1482
-      getDrawingVersion(this.scene.getElementsIncludingDeleted()),
1506
+      getSceneVersion(this.scene.getElementsIncludingDeleted()),
1483 1507
     );
1484 1508
     for (const syncableElement of syncableElements) {
1485 1509
       this.broadcastedElementVersions.set(
@@ -1487,7 +1511,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
1487 1511
         syncableElement.version,
1488 1512
       );
1489 1513
     }
1490
-    return this.portal._broadcastSocketData(data as SocketUpdateData);
1514
+
1515
+    const broadcastPromise = this.portal._broadcastSocketData(
1516
+      data as SocketUpdateData,
1517
+    );
1518
+
1519
+    if (syncAll && this.portal.roomID && this.portal.roomKey) {
1520
+      await Promise.all([
1521
+        broadcastPromise,
1522
+        saveToFirebase(
1523
+          this.portal.roomID,
1524
+          this.portal.roomKey,
1525
+          syncableElements,
1526
+        ).catch((e) => {
1527
+          console.error(e);
1528
+        }),
1529
+      ]);
1530
+    } else {
1531
+      await broadcastPromise;
1532
+    }
1491 1533
   };
1492 1534
 
1493 1535
   private onSceneUpdated = () => {

+ 127
- 0
src/data/firebase.ts Целия файл

@@ -0,0 +1,127 @@
1
+import { createIV, getImportedKey } from "./index";
2
+import { ExcalidrawElement } from "../element/types";
3
+import { getSceneVersion } from "../element";
4
+
5
+let firebasePromise: Promise<typeof import("firebase/app")> | null = null;
6
+
7
+async function loadFirebase() {
8
+  const firebase = await import("firebase/app");
9
+  await import("firebase/firestore");
10
+
11
+  const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
12
+  firebase.initializeApp(firebaseConfig);
13
+
14
+  return firebase;
15
+}
16
+
17
+async function getFirebase(): Promise<typeof import("firebase/app")> {
18
+  if (!firebasePromise) {
19
+    firebasePromise = loadFirebase();
20
+  }
21
+  const firebase = await firebasePromise!;
22
+  return firebase;
23
+}
24
+
25
+interface FirebaseStoredScene {
26
+  sceneVersion: number;
27
+  iv: firebase.firestore.Blob;
28
+  ciphertext: firebase.firestore.Blob;
29
+}
30
+
31
+async function encryptElements(
32
+  key: string,
33
+  elements: readonly ExcalidrawElement[],
34
+): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> {
35
+  const importedKey = await getImportedKey(key, "encrypt");
36
+  const iv = createIV();
37
+  const json = JSON.stringify(elements);
38
+  const encoded = new TextEncoder().encode(json);
39
+  const ciphertext = await window.crypto.subtle.encrypt(
40
+    {
41
+      name: "AES-GCM",
42
+      iv,
43
+    },
44
+    importedKey,
45
+    encoded,
46
+  );
47
+
48
+  return { ciphertext, iv };
49
+}
50
+
51
+async function decryptElements(
52
+  key: string,
53
+  iv: Uint8Array,
54
+  ciphertext: ArrayBuffer,
55
+): Promise<readonly ExcalidrawElement[]> {
56
+  const importedKey = await getImportedKey(key, "decrypt");
57
+  const decrypted = await window.crypto.subtle.decrypt(
58
+    {
59
+      name: "AES-GCM",
60
+      iv,
61
+    },
62
+    importedKey,
63
+    ciphertext,
64
+  );
65
+
66
+  const decodedData = new TextDecoder("utf-8").decode(
67
+    new Uint8Array(decrypted) as any,
68
+  );
69
+  return JSON.parse(decodedData);
70
+}
71
+
72
+export async function saveToFirebase(
73
+  roomId: string,
74
+  roomSecret: string,
75
+  elements: readonly ExcalidrawElement[],
76
+) {
77
+  const firebase = await getFirebase();
78
+  const sceneVersion = getSceneVersion(elements);
79
+  const { ciphertext, iv } = await encryptElements(roomSecret, elements);
80
+
81
+  const nextDocData = {
82
+    sceneVersion,
83
+    ciphertext: firebase.firestore.Blob.fromUint8Array(
84
+      new Uint8Array(ciphertext),
85
+    ),
86
+    iv: firebase.firestore.Blob.fromUint8Array(iv),
87
+  } as FirebaseStoredScene;
88
+
89
+  const db = firebase.firestore();
90
+  const docRef = db.collection("scenes").doc(roomId);
91
+  const didUpdate = await db.runTransaction(async (transaction) => {
92
+    const doc = await transaction.get(docRef);
93
+    if (!doc.exists) {
94
+      transaction.set(docRef, nextDocData);
95
+      return true;
96
+    }
97
+
98
+    const prevDocData = doc.data() as FirebaseStoredScene;
99
+    if (prevDocData.sceneVersion >= nextDocData.sceneVersion) {
100
+      return false;
101
+    }
102
+
103
+    transaction.update(docRef, nextDocData);
104
+    return true;
105
+  });
106
+
107
+  return didUpdate;
108
+}
109
+
110
+export async function loadFromFirebase(
111
+  roomId: string,
112
+  roomSecret: string,
113
+): Promise<readonly ExcalidrawElement[] | null> {
114
+  const firebase = await getFirebase();
115
+  const db = firebase.firestore();
116
+
117
+  const docRef = db.collection("scenes").doc(roomId);
118
+  const doc = await docRef.get();
119
+  if (!doc.exists) {
120
+    return null;
121
+  }
122
+  const storedScene = doc.data() as FirebaseStoredScene;
123
+  const ciphertext = storedScene.ciphertext.toUint8Array();
124
+  const iv = storedScene.iv.toUint8Array();
125
+  const plaintext = await decryptElements(roomSecret, iv, ciphertext);
126
+  return plaintext;
127
+}

+ 2
- 2
src/data/index.ts Целия файл

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

+ 1
- 1
src/data/restore.ts Целия файл

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

+ 1
- 1
src/element/index.ts Целия файл

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

+ 1
- 0
src/global.d.ts Целия файл

@@ -20,6 +20,7 @@ declare namespace NodeJS {
20 20
     readonly REACT_APP_BACKEND_V2_GET_URL: string;
21 21
     readonly REACT_APP_BACKEND_V2_POST_URL: string;
22 22
     readonly REACT_APP_SOCKET_SERVER_URL: string;
23
+    readonly REACT_APP_FIREBASE_CONFIG: string;
23 24
   }
24 25
 }
25 26
 

Loading…
Отказ
Запис