2024-08-23 15:54:59 +02:00
|
|
|
import { reconcileElements } from "../../packages/excalidraw";
|
|
|
|
import type {
|
2023-12-12 11:32:51 +05:30
|
|
|
ExcalidrawElement,
|
|
|
|
FileId,
|
2024-08-23 15:54:59 +02:00
|
|
|
OrderedExcalidrawElement,
|
2023-12-12 11:32:51 +05:30
|
|
|
} from "../../packages/excalidraw/element/types";
|
|
|
|
import { getSceneVersion } from "../../packages/excalidraw/element";
|
2024-08-23 15:54:59 +02:00
|
|
|
import type Portal from "../collab/Portal";
|
2023-12-12 11:32:51 +05:30
|
|
|
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
2024-08-23 15:54:59 +02:00
|
|
|
import type {
|
2022-04-17 22:40:39 +02:00
|
|
|
AppState,
|
|
|
|
BinaryFileData,
|
|
|
|
BinaryFileMetadata,
|
|
|
|
DataURL,
|
2023-12-12 11:32:51 +05:30
|
|
|
} from "../../packages/excalidraw/types";
|
2021-10-21 22:05:48 +02:00
|
|
|
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
2023-12-12 11:32:51 +05:30
|
|
|
import { decompressData } from "../../packages/excalidraw/data/encode";
|
|
|
|
import {
|
|
|
|
encryptData,
|
|
|
|
decryptData,
|
|
|
|
} from "../../packages/excalidraw/data/encryption";
|
|
|
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
2024-08-23 15:54:59 +02:00
|
|
|
import type { SyncableExcalidrawElement } from ".";
|
|
|
|
import { getSyncableElements } from ".";
|
|
|
|
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
2024-05-27 16:11:57 +02:00
|
|
|
import type { Socket } from "socket.io-client";
|
2024-08-23 15:54:59 +02:00
|
|
|
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2021-06-01 14:05:09 +02:00
|
|
|
// private
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
2021-12-09 13:24:41 +01:00
|
|
|
let FIREBASE_CONFIG: Record<string, any>;
|
|
|
|
try {
|
2023-07-27 23:50:11 +05:30
|
|
|
FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
|
2021-12-09 13:24:41 +01:00
|
|
|
} catch (error: any) {
|
|
|
|
console.warn(
|
2023-07-27 23:50:11 +05:30
|
|
|
`Error JSON parsing firebase config. Supplied value: ${
|
|
|
|
import.meta.env.VITE_APP_FIREBASE_CONFIG
|
|
|
|
}`,
|
2021-12-09 13:24:41 +01:00
|
|
|
);
|
|
|
|
FIREBASE_CONFIG = {};
|
|
|
|
}
|
2021-10-21 22:05:48 +02:00
|
|
|
|
2021-11-01 15:24:05 +02:00
|
|
|
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
|
|
|
null;
|
2021-10-21 22:05:48 +02:00
|
|
|
let firestorePromise: Promise<any> | null | true = null;
|
|
|
|
let firebaseStoragePromise: Promise<any> | null | true = null;
|
|
|
|
|
|
|
|
let isFirebaseInitialized = false;
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2021-06-01 14:05:09 +02:00
|
|
|
const _loadFirebase = async () => {
|
2020-10-29 16:10:46 +01:00
|
|
|
const firebase = (
|
|
|
|
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
|
|
|
).default;
|
2024-01-01 18:05:48 +01:00
|
|
|
const storage = import.meta.env.VITE_APP_STORAGE_BACKEND;
|
|
|
|
const useFirebase = storage === "firebase";
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2024-01-01 18:05:48 +01:00
|
|
|
if (useFirebase && !isFirebaseInitialized) {
|
2021-10-21 22:05:48 +02:00
|
|
|
try {
|
|
|
|
firebase.initializeApp(FIREBASE_CONFIG);
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2021-10-21 22:05:48 +02:00
|
|
|
// trying initialize again throws. Usually this is harmless, and happens
|
|
|
|
// mainly in dev (HMR)
|
|
|
|
if (error.code === "app/duplicate-app") {
|
|
|
|
console.warn(error.name, error.code);
|
|
|
|
} else {
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
isFirebaseInitialized = true;
|
|
|
|
}
|
2020-10-04 11:12:47 -07:00
|
|
|
|
|
|
|
return firebase;
|
2020-11-06 22:06:39 +02:00
|
|
|
};
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2021-06-01 14:05:09 +02:00
|
|
|
const _getFirebase = async (): Promise<
|
2020-11-06 22:06:39 +02:00
|
|
|
typeof import("firebase/app").default
|
|
|
|
> => {
|
2020-10-04 11:12:47 -07:00
|
|
|
if (!firebasePromise) {
|
2021-06-01 14:05:09 +02:00
|
|
|
firebasePromise = _loadFirebase();
|
|
|
|
}
|
|
|
|
return firebasePromise;
|
|
|
|
};
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
const loadFirestore = async () => {
|
|
|
|
const firebase = await _getFirebase();
|
|
|
|
if (!firestorePromise) {
|
|
|
|
firestorePromise = import(
|
|
|
|
/* webpackChunkName: "firestore" */ "firebase/firestore"
|
|
|
|
);
|
2021-10-21 22:05:48 +02:00
|
|
|
}
|
|
|
|
if (firestorePromise !== true) {
|
2021-06-01 14:05:09 +02:00
|
|
|
await firestorePromise;
|
2021-10-21 22:05:48 +02:00
|
|
|
firestorePromise = true;
|
2020-10-04 11:12:47 -07:00
|
|
|
}
|
2021-06-01 14:05:09 +02:00
|
|
|
return firebase;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const loadFirebaseStorage = async () => {
|
|
|
|
const firebase = await _getFirebase();
|
2021-10-21 22:05:48 +02:00
|
|
|
if (!firebaseStoragePromise) {
|
|
|
|
firebaseStoragePromise = import(
|
2021-06-01 14:05:09 +02:00
|
|
|
/* webpackChunkName: "storage" */ "firebase/storage"
|
|
|
|
);
|
2021-10-21 22:05:48 +02:00
|
|
|
}
|
|
|
|
if (firebaseStoragePromise !== true) {
|
|
|
|
await firebaseStoragePromise;
|
|
|
|
firebaseStoragePromise = true;
|
2021-06-01 14:05:09 +02:00
|
|
|
}
|
|
|
|
return firebase;
|
2020-11-06 22:06:39 +02:00
|
|
|
};
|
2020-10-04 11:12:47 -07:00
|
|
|
|
|
|
|
interface FirebaseStoredScene {
|
|
|
|
sceneVersion: number;
|
2020-10-29 16:10:46 +01:00
|
|
|
iv: firebase.default.firestore.Blob;
|
|
|
|
ciphertext: firebase.default.firestore.Blob;
|
2020-10-04 11:12:47 -07:00
|
|
|
}
|
|
|
|
|
2020-11-06 22:06:39 +02:00
|
|
|
const encryptElements = async (
|
2020-10-04 11:12:47 -07:00
|
|
|
key: string,
|
|
|
|
elements: readonly ExcalidrawElement[],
|
2020-11-06 22:06:39 +02:00
|
|
|
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
|
2020-10-04 11:12:47 -07:00
|
|
|
const json = JSON.stringify(elements);
|
|
|
|
const encoded = new TextEncoder().encode(json);
|
2021-11-07 14:33:21 +01:00
|
|
|
const { encryptedBuffer, iv } = await encryptData(key, encoded);
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2021-11-07 14:33:21 +01:00
|
|
|
return { ciphertext: encryptedBuffer, iv };
|
2020-11-06 22:06:39 +02:00
|
|
|
};
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2020-11-06 22:06:39 +02:00
|
|
|
const decryptElements = async (
|
2022-04-17 22:40:39 +02:00
|
|
|
data: FirebaseStoredScene,
|
|
|
|
roomKey: string,
|
2020-11-06 22:06:39 +02:00
|
|
|
): Promise<readonly ExcalidrawElement[]> => {
|
2022-04-17 22:40:39 +02:00
|
|
|
const ciphertext = data.ciphertext.toUint8Array();
|
|
|
|
const iv = data.iv.toUint8Array();
|
|
|
|
|
|
|
|
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
2020-10-04 11:12:47 -07:00
|
|
|
const decodedData = new TextDecoder("utf-8").decode(
|
2021-10-21 22:05:48 +02:00
|
|
|
new Uint8Array(decrypted),
|
2020-10-04 11:12:47 -07:00
|
|
|
);
|
|
|
|
return JSON.parse(decodedData);
|
2020-11-06 22:06:39 +02:00
|
|
|
};
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2022-05-09 08:38:44 -05:00
|
|
|
class FirebaseSceneVersionCache {
|
2024-05-27 16:11:57 +02:00
|
|
|
private static cache = new WeakMap<Socket, number>();
|
|
|
|
static get = (socket: Socket) => {
|
2022-05-09 08:38:44 -05:00
|
|
|
return FirebaseSceneVersionCache.cache.get(socket);
|
|
|
|
};
|
|
|
|
static set = (
|
2024-05-27 16:11:57 +02:00
|
|
|
socket: Socket,
|
2022-05-09 08:38:44 -05:00
|
|
|
elements: readonly SyncableExcalidrawElement[],
|
|
|
|
) => {
|
|
|
|
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
|
|
|
|
};
|
|
|
|
}
|
2020-10-06 04:34:40 +02:00
|
|
|
|
|
|
|
export const isSavedToFirebase = (
|
|
|
|
portal: Portal,
|
|
|
|
elements: readonly ExcalidrawElement[],
|
|
|
|
): boolean => {
|
2020-11-29 18:32:51 +02:00
|
|
|
if (portal.socket && portal.roomId && portal.roomKey) {
|
2020-10-06 04:34:40 +02:00
|
|
|
const sceneVersion = getSceneVersion(elements);
|
2021-10-21 22:05:48 +02:00
|
|
|
|
2022-05-09 08:38:44 -05:00
|
|
|
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
|
2020-10-06 04:34:40 +02:00
|
|
|
}
|
|
|
|
// if no room exists, consider the room saved so that we don't unnecessarily
|
2020-11-05 19:06:18 +02:00
|
|
|
// prevent unload (there's nothing we could do at that point anyway)
|
2020-10-06 04:34:40 +02:00
|
|
|
return true;
|
|
|
|
};
|
|
|
|
|
2021-10-21 22:05:48 +02:00
|
|
|
export const saveFilesToFirebase = async ({
|
|
|
|
prefix,
|
|
|
|
files,
|
|
|
|
}: {
|
|
|
|
prefix: string;
|
|
|
|
files: { id: FileId; buffer: Uint8Array }[];
|
|
|
|
}) => {
|
|
|
|
const firebase = await loadFirebaseStorage();
|
|
|
|
|
|
|
|
const erroredFiles = new Map<FileId, true>();
|
|
|
|
const savedFiles = new Map<FileId, true>();
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
files.map(async ({ id, buffer }) => {
|
|
|
|
try {
|
|
|
|
await firebase
|
|
|
|
.storage()
|
|
|
|
.ref(`${prefix}/${id}`)
|
|
|
|
.put(
|
|
|
|
new Blob([buffer], {
|
|
|
|
type: MIME_TYPES.binary,
|
|
|
|
}),
|
|
|
|
{
|
|
|
|
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
savedFiles.set(id, true);
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2021-10-21 22:05:48 +02:00
|
|
|
erroredFiles.set(id, true);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
|
|
|
|
return { savedFiles, erroredFiles };
|
|
|
|
};
|
|
|
|
|
2022-04-17 22:40:39 +02:00
|
|
|
const createFirebaseSceneDocument = async (
|
|
|
|
firebase: ResolutionType<typeof loadFirestore>,
|
2022-05-09 08:38:44 -05:00
|
|
|
elements: readonly SyncableExcalidrawElement[],
|
2022-04-17 22:40:39 +02:00
|
|
|
roomKey: string,
|
|
|
|
) => {
|
|
|
|
const sceneVersion = getSceneVersion(elements);
|
|
|
|
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
|
|
|
return {
|
|
|
|
sceneVersion,
|
|
|
|
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
|
|
|
new Uint8Array(ciphertext),
|
|
|
|
),
|
|
|
|
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
|
|
|
} as FirebaseStoredScene;
|
|
|
|
};
|
|
|
|
|
2020-11-06 22:06:39 +02:00
|
|
|
export const saveToFirebase = async (
|
2020-10-06 04:34:40 +02:00
|
|
|
portal: Portal,
|
2022-05-09 08:38:44 -05:00
|
|
|
elements: readonly SyncableExcalidrawElement[],
|
2022-04-17 22:40:39 +02:00
|
|
|
appState: AppState,
|
2020-11-06 22:06:39 +02:00
|
|
|
) => {
|
2020-11-29 18:32:51 +02:00
|
|
|
const { roomId, roomKey, socket } = portal;
|
2020-10-06 04:34:40 +02:00
|
|
|
if (
|
2022-04-17 22:40:39 +02:00
|
|
|
// bail if no room exists as there's nothing we can do at this point
|
2020-11-29 18:32:51 +02:00
|
|
|
!roomId ||
|
2020-10-06 04:34:40 +02:00
|
|
|
!roomKey ||
|
|
|
|
!socket ||
|
|
|
|
isSavedToFirebase(portal, elements)
|
|
|
|
) {
|
2024-08-23 15:54:59 +02:00
|
|
|
return null;
|
2020-10-06 04:34:40 +02:00
|
|
|
}
|
|
|
|
|
2021-06-01 14:05:09 +02:00
|
|
|
const firebase = await loadFirestore();
|
2022-04-17 22:40:39 +02:00
|
|
|
const firestore = firebase.firestore();
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2022-04-17 22:40:39 +02:00
|
|
|
const docRef = firestore.collection("scenes").doc(roomId);
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const storedScene = await firestore.runTransaction(async (transaction) => {
|
2022-04-17 22:40:39 +02:00
|
|
|
const snapshot = await transaction.get(docRef);
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2022-04-17 22:40:39 +02:00
|
|
|
if (!snapshot.exists) {
|
2024-08-23 15:54:59 +02:00
|
|
|
const storedScene = await createFirebaseSceneDocument(
|
2022-04-17 22:40:39 +02:00
|
|
|
firebase,
|
|
|
|
elements,
|
|
|
|
roomKey,
|
|
|
|
);
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
transaction.set(docRef, storedScene);
|
2022-04-17 22:40:39 +02:00
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
return storedScene;
|
2020-10-04 11:12:47 -07:00
|
|
|
}
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
|
|
|
|
const prevStoredElements = getSyncableElements(
|
|
|
|
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
|
2022-05-09 08:38:44 -05:00
|
|
|
);
|
|
|
|
const reconciledElements = getSyncableElements(
|
2024-08-23 15:54:59 +02:00
|
|
|
reconcileElements(
|
|
|
|
elements,
|
|
|
|
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
|
|
|
appState,
|
|
|
|
),
|
2022-04-17 22:40:39 +02:00
|
|
|
);
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const storedScene = await createFirebaseSceneDocument(
|
2022-04-17 22:40:39 +02:00
|
|
|
firebase,
|
|
|
|
reconciledElements,
|
|
|
|
roomKey,
|
|
|
|
);
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
transaction.update(docRef, storedScene);
|
|
|
|
|
|
|
|
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
|
|
|
|
return storedScene;
|
2020-10-04 11:12:47 -07:00
|
|
|
});
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const storedElements = getSyncableElements(
|
|
|
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
|
|
|
);
|
|
|
|
|
|
|
|
FirebaseSceneVersionCache.set(socket, storedElements);
|
2020-10-06 04:34:40 +02:00
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
return storedElements;
|
2020-11-06 22:06:39 +02:00
|
|
|
};
|
2020-10-04 11:12:47 -07:00
|
|
|
|
2020-11-06 22:06:39 +02:00
|
|
|
export const loadFromFirebase = async (
|
2020-11-29 18:32:51 +02:00
|
|
|
roomId: string,
|
2020-10-06 04:34:40 +02:00
|
|
|
roomKey: string,
|
2024-05-27 16:11:57 +02:00
|
|
|
socket: Socket | null,
|
2024-08-23 15:54:59 +02:00
|
|
|
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
2021-06-01 14:05:09 +02:00
|
|
|
const firebase = await loadFirestore();
|
2020-10-04 11:12:47 -07:00
|
|
|
const db = firebase.firestore();
|
|
|
|
|
2020-11-29 18:32:51 +02:00
|
|
|
const docRef = db.collection("scenes").doc(roomId);
|
2020-10-04 11:12:47 -07:00
|
|
|
const doc = await docRef.get();
|
|
|
|
if (!doc.exists) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
const storedScene = doc.data() as FirebaseStoredScene;
|
2022-05-09 08:38:44 -05:00
|
|
|
const elements = getSyncableElements(
|
2024-08-23 15:54:59 +02:00
|
|
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
2022-05-09 08:38:44 -05:00
|
|
|
);
|
2021-02-03 19:14:26 +01:00
|
|
|
|
|
|
|
if (socket) {
|
2022-05-09 08:38:44 -05:00
|
|
|
FirebaseSceneVersionCache.set(socket, elements);
|
2021-02-03 19:14:26 +01:00
|
|
|
}
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
return elements;
|
2020-11-06 22:06:39 +02:00
|
|
|
};
|
2021-10-21 22:05:48 +02:00
|
|
|
|
|
|
|
export const loadFilesFromFirebase = async (
|
|
|
|
prefix: string,
|
|
|
|
decryptionKey: string,
|
|
|
|
filesIds: readonly FileId[],
|
|
|
|
) => {
|
|
|
|
const loadedFiles: BinaryFileData[] = [];
|
|
|
|
const erroredFiles = new Map<FileId, true>();
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
[...new Set(filesIds)].map(async (id) => {
|
|
|
|
try {
|
|
|
|
const url = `https://firebasestorage.googleapis.com/v0/b/${
|
|
|
|
FIREBASE_CONFIG.storageBucket
|
|
|
|
}/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
|
|
|
|
const response = await fetch(`${url}?alt=media`);
|
|
|
|
if (response.status < 400) {
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
|
|
|
|
|
|
const { data, metadata } = await decompressData<BinaryFileMetadata>(
|
|
|
|
new Uint8Array(arrayBuffer),
|
|
|
|
{
|
|
|
|
decryptionKey,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
const dataURL = new TextDecoder().decode(data) as DataURL;
|
|
|
|
|
|
|
|
loadedFiles.push({
|
|
|
|
mimeType: metadata.mimeType || MIME_TYPES.binary,
|
|
|
|
id,
|
|
|
|
dataURL,
|
|
|
|
created: metadata?.created || Date.now(),
|
2022-11-06 19:41:14 +01:00
|
|
|
lastRetrieved: metadata?.created || Date.now(),
|
2021-10-21 22:05:48 +02:00
|
|
|
});
|
2021-11-01 10:44:57 +01:00
|
|
|
} else {
|
|
|
|
erroredFiles.set(id, true);
|
2021-10-21 22:05:48 +02:00
|
|
|
}
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2021-10-21 22:05:48 +02:00
|
|
|
erroredFiles.set(id, true);
|
|
|
|
console.error(error);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
|
|
|
|
return { loadedFiles, erroredFiles };
|
|
|
|
};
|