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

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