excalidraw/src/excalidraw-app/data/firebase.ts

201 lines
5.3 KiB
TypeScript

import { getImportedKey } from "../data";
import { createIV } from "./index";
import { ExcalidrawElement } from "../../element/types";
import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore";
// private
// -----------------------------------------------------------------------------
let firebasePromise: Promise<
typeof import("firebase/app").default
> | null = null;
let firestorePromise: Promise<any> | null = null;
let firebseStoragePromise: Promise<any> | null = null;
const _loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
firebase.initializeApp(firebaseConfig);
return firebase;
};
const _getFirebase = async (): Promise<
typeof import("firebase/app").default
> => {
if (!firebasePromise) {
firebasePromise = _loadFirebase();
}
return firebasePromise;
};
// -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
await firestorePromise;
}
return firebase;
};
export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase();
if (!firebseStoragePromise) {
firebseStoragePromise = import(
/* webpackChunkName: "storage" */ "firebase/storage"
);
await firebseStoragePromise;
}
return firebase;
};
interface FirebaseStoredScene {
sceneVersion: number;
iv: firebase.default.firestore.Blob;
ciphertext: firebase.default.firestore.Blob;
}
const encryptElements = async (
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 };
};
const decryptElements = async (
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);
};
const firebaseSceneVersionCache = new WeakMap<SocketIOClient.Socket, number>();
export const isSavedToFirebase = (
portal: Portal,
elements: readonly ExcalidrawElement[],
): boolean => {
if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(elements);
return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
}
// if no room exists, consider the room saved so that we don't unnecessarily
// prevent unload (there's nothing we could do at that point anyway)
return true;
};
export const saveToFirebase = async (
portal: Portal,
elements: readonly ExcalidrawElement[],
) => {
const { roomId, roomKey, socket } = portal;
if (
// if no room exists, consider the room saved because there's nothing we can
// do at this point
!roomId ||
!roomKey ||
!socket ||
isSavedToFirebase(portal, elements)
) {
return true;
}
const firebase = await loadFirestore();
const sceneVersion = getSceneVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, 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;
});
if (didUpdate) {
firebaseSceneVersionCache.set(socket, sceneVersion);
}
return didUpdate;
};
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
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 elements = await decryptElements(roomKey, iv, ciphertext);
if (socket) {
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
}
return restoreElements(elements);
};