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

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,