snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
319
excalidraw-app/data/firebase.ts
Normal file
319
excalidraw-app/data/firebase.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { reconcileElements } from "@excalidraw/excalidraw";
|
||||
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
|
||||
import { decompressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import {
|
||||
encryptData,
|
||||
decryptData,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
|
||||
import { getSceneVersion } from "@excalidraw/element";
|
||||
import { initializeApp } from "firebase/app";
|
||||
import {
|
||||
getFirestore,
|
||||
doc,
|
||||
getDoc,
|
||||
runTransaction,
|
||||
Bytes,
|
||||
} from "firebase/firestore";
|
||||
import { getStorage, ref, uploadBytes } from "firebase/storage";
|
||||
|
||||
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
DataURL,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
||||
|
||||
import { getSyncableElements } from ".";
|
||||
|
||||
import type { SyncableExcalidrawElement } from ".";
|
||||
import type Portal from "../collab/Portal";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
let FIREBASE_CONFIG: Record<string, any>;
|
||||
try {
|
||||
FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Error JSON parsing firebase config. Supplied value: ${
|
||||
import.meta.env.VITE_APP_FIREBASE_CONFIG
|
||||
}`,
|
||||
);
|
||||
FIREBASE_CONFIG = {};
|
||||
}
|
||||
|
||||
let firebaseApp: ReturnType<typeof initializeApp> | null = null;
|
||||
let firestore: ReturnType<typeof getFirestore> | null = null;
|
||||
let firebaseStorage: ReturnType<typeof getStorage> | null = null;
|
||||
|
||||
const _initializeFirebase = () => {
|
||||
if (!firebaseApp) {
|
||||
firebaseApp = initializeApp(FIREBASE_CONFIG);
|
||||
}
|
||||
return firebaseApp;
|
||||
};
|
||||
|
||||
const _getFirestore = () => {
|
||||
if (!firestore) {
|
||||
firestore = getFirestore(_initializeFirebase());
|
||||
}
|
||||
return firestore;
|
||||
};
|
||||
|
||||
const _getStorage = () => {
|
||||
if (!firebaseStorage) {
|
||||
firebaseStorage = getStorage(_initializeFirebase());
|
||||
}
|
||||
return firebaseStorage;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const loadFirebaseStorage = async () => {
|
||||
return _getStorage();
|
||||
};
|
||||
|
||||
type FirebaseStoredScene = {
|
||||
sceneVersion: number;
|
||||
iv: Bytes;
|
||||
ciphertext: Bytes;
|
||||
};
|
||||
|
||||
const encryptElements = async (
|
||||
key: string,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
|
||||
const json = JSON.stringify(elements);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const { encryptedBuffer, iv } = await encryptData(key, encoded);
|
||||
|
||||
return { ciphertext: encryptedBuffer, iv };
|
||||
};
|
||||
|
||||
const decryptElements = async (
|
||||
data: FirebaseStoredScene,
|
||||
roomKey: string,
|
||||
): Promise<readonly ExcalidrawElement[]> => {
|
||||
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||
|
||||
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
return JSON.parse(decodedData);
|
||||
};
|
||||
|
||||
class FirebaseSceneVersionCache {
|
||||
private static cache = new WeakMap<Socket, number>();
|
||||
static get = (socket: Socket) => {
|
||||
return FirebaseSceneVersionCache.cache.get(socket);
|
||||
};
|
||||
static set = (
|
||||
socket: Socket,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
|
||||
};
|
||||
}
|
||||
|
||||
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 saveFilesToFirebase = async ({
|
||||
prefix,
|
||||
files,
|
||||
}: {
|
||||
prefix: string;
|
||||
files: { id: FileId; buffer: Uint8Array }[];
|
||||
}) => {
|
||||
const storage = await loadFirebaseStorage();
|
||||
|
||||
const erroredFiles: FileId[] = [];
|
||||
const savedFiles: FileId[] = [];
|
||||
|
||||
await Promise.all(
|
||||
files.map(async ({ id, buffer }) => {
|
||||
try {
|
||||
const storageRef = ref(storage, `${prefix}/${id}`);
|
||||
await uploadBytes(storageRef, buffer, {
|
||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||
});
|
||||
savedFiles.push(id);
|
||||
} catch (error: any) {
|
||||
erroredFiles.push(id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { savedFiles, erroredFiles };
|
||||
};
|
||||
|
||||
const createFirebaseSceneDocument = async (
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
roomKey: string,
|
||||
) => {
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
return {
|
||||
sceneVersion,
|
||||
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
|
||||
iv: Bytes.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
};
|
||||
|
||||
export const saveToFirebase = async (
|
||||
portal: Portal,
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const { roomId, roomKey, socket } = portal;
|
||||
if (
|
||||
// bail if no room exists as there's nothing we can do at this point
|
||||
!roomId ||
|
||||
!roomKey ||
|
||||
!socket ||
|
||||
isSavedToFirebase(portal, elements)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firestore = _getFirestore();
|
||||
const docRef = doc(firestore, "scenes", roomId);
|
||||
|
||||
const storedScene = await runTransaction(firestore, async (transaction) => {
|
||||
const snapshot = await transaction.get(docRef);
|
||||
|
||||
if (!snapshot.exists()) {
|
||||
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
|
||||
|
||||
transaction.set(docRef, storedScene);
|
||||
|
||||
return storedScene;
|
||||
}
|
||||
|
||||
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
|
||||
const prevStoredElements = getSyncableElements(
|
||||
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
|
||||
);
|
||||
const reconciledElements = getSyncableElements(
|
||||
reconcileElements(
|
||||
elements,
|
||||
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
||||
appState,
|
||||
),
|
||||
);
|
||||
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
reconciledElements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.update(docRef, storedScene);
|
||||
|
||||
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
|
||||
return storedScene;
|
||||
});
|
||||
|
||||
const storedElements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
);
|
||||
|
||||
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||
|
||||
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
socket: Socket | null,
|
||||
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||
const firestore = _getFirestore();
|
||||
const docRef = doc(firestore, "scenes", roomId);
|
||||
const docSnap = await getDoc(docRef);
|
||||
if (!docSnap.exists()) {
|
||||
return null;
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
FirebaseSceneVersionCache.set(socket, elements);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
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(),
|
||||
lastRetrieved: metadata?.created || Date.now(),
|
||||
});
|
||||
} else {
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
erroredFiles.set(id, true);
|
||||
console.error(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
};
|
||||
Reference in New Issue
Block a user