mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
f2135ab739
commit
d0985fe67a
14 changed files with 4419 additions and 18 deletions
127
src/data/firebase.ts
Normal file
127
src/data/firebase.ts
Normal 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;
|
||||
}
|
|
@ -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",
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue