Persistent rooms via Firebase (#2188)

* Periodically back up collaborative rooms in firebase

* Responses to code review

* comments from code review, new firebase credentials
This commit is contained in:
Pete Hunt 2020-10-04 11:12:47 -07:00 committed by GitHub
parent f2135ab739
commit d0985fe67a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 4419 additions and 18 deletions

1
.env
View File

@ -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"}'

View File

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

66
firebase-project/.gitignore vendored Normal file
View File

@ -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

View File

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

View File

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

View File

@ -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;
}
}
}

4137
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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
src/data/firebase.ts Normal file
View File

@ -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;
}

View File

@ -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",
{

View File

@ -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,

View File

@ -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
src/global.d.ts vendored
View File

@ -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;
}
}