Store and load scenes to and from collab server instead of firebase
This commit is contained in:
parent
c0c4d4bacb
commit
cbcff42853
@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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> => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user