Store and load scenes to and from collab server instead of firebase

This commit is contained in:
Tims777 2024-09-26 16:25:05 +02:00
parent c0c4d4bacb
commit cbcff42853
2 changed files with 58 additions and 80 deletions

View File

@ -4,7 +4,7 @@ import type {
FileId, FileId,
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element"; import { hashElementsVersion } from "../../packages/excalidraw/element";
import type Portal from "../collab/Portal"; import type Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore"; import { restoreElements } from "../../packages/excalidraw/data/restore";
import type { import type {
@ -18,11 +18,11 @@ import { decompressData } from "../../packages/excalidraw/data/encode";
import { import {
encryptData, encryptData,
decryptData, decryptData,
IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption"; } from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants"; import { MIME_TYPES } from "../../packages/excalidraw/constants";
import type { SyncableExcalidrawElement } from "."; import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from "."; import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile"; import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
@ -43,7 +43,6 @@ try {
let firebasePromise: Promise<typeof import("firebase/app").default> | null = let firebasePromise: Promise<typeof import("firebase/app").default> | null =
null; null;
let firestorePromise: Promise<any> | null | true = null;
let firebaseStoragePromise: Promise<any> | null | true = null; let firebaseStoragePromise: Promise<any> | null | true = null;
let isFirebaseInitialized = false; let isFirebaseInitialized = false;
@ -82,20 +81,6 @@ const _getFirebase = async (): Promise<
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const loadFirestore = async () => {
const firebase = await _getFirebase();
if (!firestorePromise) {
firestorePromise = import(
/* webpackChunkName: "firestore" */ "firebase/firestore"
);
}
if (firestorePromise !== true) {
await firestorePromise;
firestorePromise = true;
}
return firebase;
};
export const loadFirebaseStorage = async () => { export const loadFirebaseStorage = async () => {
const firebase = await _getFirebase(); const firebase = await _getFirebase();
if (!firebaseStoragePromise) { if (!firebaseStoragePromise) {
@ -110,10 +95,10 @@ export const loadFirebaseStorage = async () => {
return firebase; return firebase;
}; };
interface FirebaseStoredScene { interface StoredScene {
sceneVersion: number; sceneVersion: number;
iv: firebase.default.firestore.Blob; iv: ArrayBuffer;
ciphertext: firebase.default.firestore.Blob; ciphertext: ArrayBuffer;
} }
const encryptElements = async ( const encryptElements = async (
@ -128,11 +113,11 @@ const encryptElements = async (
}; };
const decryptElements = async ( const decryptElements = async (
data: FirebaseStoredScene, data: StoredScene,
roomKey: string, roomKey: string,
): Promise<readonly ExcalidrawElement[]> => { ): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array(); const ciphertext = data.ciphertext;
const iv = data.iv.toUint8Array(); const iv = data.iv;
const decrypted = await decryptData(iv, ciphertext, roomKey); const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode( const decodedData = new TextDecoder("utf-8").decode(
@ -150,7 +135,7 @@ class FirebaseSceneVersionCache {
socket: Socket, socket: Socket,
elements: readonly SyncableExcalidrawElement[], elements: readonly SyncableExcalidrawElement[],
) => { ) => {
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements)); FirebaseSceneVersionCache.cache.set(socket, hashElementsVersion(elements));
}; };
} }
@ -159,7 +144,7 @@ export const isSavedToFirebase = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): boolean => { ): boolean => {
if (portal.socket && portal.roomId && portal.roomKey) { if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(elements); const sceneVersion = hashElementsVersion(elements);
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion; return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
} }
@ -204,20 +189,17 @@ export const saveFilesToFirebase = async ({
return { savedFiles, erroredFiles }; return { savedFiles, erroredFiles };
}; };
const createFirebaseSceneDocument = async ( const createSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly SyncableExcalidrawElement[], elements: readonly SyncableExcalidrawElement[],
roomKey: string, roomKey: string,
) => { ) => {
const sceneVersion = getSceneVersion(elements); const sceneVersion = hashElementsVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, elements); const { ciphertext, iv } = await encryptElements(roomKey, elements);
return { return {
sceneVersion, sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array( ciphertext,
new Uint8Array(ciphertext), iv,
), } as StoredScene;
iv: firebase.firestore.Blob.fromUint8Array(iv),
} as FirebaseStoredScene;
}; };
export const saveToFirebase = async ( export const saveToFirebase = async (
@ -236,50 +218,40 @@ export const saveToFirebase = async (
return null; return null;
} }
const firebase = await loadFirestore(); // Step 1: Retrieve most recent scene from server
const firestore = firebase.firestore(); const prevStoredElements =
(await loadFromFirebase(roomId, roomKey, socket)) ?? [];
const prevHash = hashElementsVersion(elements);
const docRef = firestore.collection("scenes").doc(roomId); // Step 2: Merge local changes to calculate new scene
const reconciledElements = getSyncableElements(
reconcileElements(
elements,
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
);
const storedScene = await firestore.runTransaction(async (transaction) => { const storedScene = await createSceneDocument(reconciledElements, roomKey);
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) { // Step 3: Try to replace scene on server
const storedScene = await createFirebaseSceneDocument( // TODO: What if scene on server has been updated in the meantime? (response code 409)
firebase, const body = new Uint8Array(
elements, storedScene.iv.byteLength + storedScene.ciphertext.byteLength,
roomKey, );
); body.set(new Uint8Array(storedScene.iv), 0);
body.set(new Uint8Array(storedScene.ciphertext), storedScene.iv.byteLength);
transaction.set(docRef, storedScene); await fetch(`https://room.tisizi.de/scene/${roomId}`, {
method: "PUT",
return storedScene; headers: {
} ETag: storedScene.sceneVersion.toString(),
"If-Match": prevHash.toString(),
const prevStoredScene = snapshot.data() as FirebaseStoredScene; "Content-Type": "application/octet-stream",
const prevStoredElements = getSyncableElements( },
restoreElements(await decryptElements(prevStoredScene, roomKey), null), body,
);
const reconciledElements = getSyncableElements(
reconcileElements(
elements,
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
);
const storedScene = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, storedScene);
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
return storedScene;
}); });
// Step 4: Update version cache
const storedElements = getSyncableElements( const storedElements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null), restoreElements(await decryptElements(storedScene, roomKey), null),
); );
@ -293,16 +265,22 @@ export const loadFromFirebase = async (
roomId: string, roomId: string,
roomKey: string, roomKey: string,
socket: Socket | null, socket: Socket | null,
): Promise<readonly SyncableExcalidrawElement[] | null> => { ): Promise<SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore(); const resp = await fetch(`https://room.tisizi.de/scene/${roomId}`, {
const db = firebase.firestore(); method: "GET",
});
const docRef = db.collection("scenes").doc(roomId); if (resp.status === 404) {
const doc = await docRef.get();
if (!doc.exists) {
return null; return null;
} }
const storedScene = doc.data() as FirebaseStoredScene;
const data = await resp.arrayBuffer();
const storedScene: StoredScene = {
iv: data.slice(0, IV_LENGTH_BYTES),
ciphertext: data.slice(IV_LENGTH_BYTES),
sceneVersion: parseInt(resp.headers.get("ETag")!),
};
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null), restoreElements(await decryptElements(storedScene, roomKey), null),
); );

View File

@ -77,7 +77,7 @@ export const encryptData = async (
}; };
export const decryptData = async ( export const decryptData = async (
iv: Uint8Array, iv: Uint8Array | ArrayBuffer,
encrypted: Uint8Array | ArrayBuffer, encrypted: Uint8Array | ArrayBuffer,
privateKey: string, privateKey: string,
): Promise<ArrayBuffer> => { ): Promise<ArrayBuffer> => {