firebase.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import { reconcileElements } from "../../packages/excalidraw";
  2. import type {
  3. ExcalidrawElement,
  4. FileId,
  5. OrderedExcalidrawElement,
  6. } from "../../packages/excalidraw/element/types";
  7. import { getSceneVersion } from "../../packages/excalidraw/element";
  8. import type Portal from "../collab/Portal";
  9. import { restoreElements } from "../../packages/excalidraw/data/restore";
  10. import type {
  11. AppState,
  12. BinaryFileData,
  13. BinaryFileMetadata,
  14. DataURL,
  15. } from "../../packages/excalidraw/types";
  16. import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
  17. import { decompressData } from "../../packages/excalidraw/data/encode";
  18. import {
  19. encryptData,
  20. decryptData,
  21. } from "../../packages/excalidraw/data/encryption";
  22. import { MIME_TYPES } from "../../packages/excalidraw/constants";
  23. import type { SyncableExcalidrawElement } from ".";
  24. import { getSyncableElements } from ".";
  25. import type { ResolutionType } from "../../packages/excalidraw/utility-types";
  26. import type { Socket } from "socket.io-client";
  27. import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
  28. // private
  29. // -----------------------------------------------------------------------------
  30. let FIREBASE_CONFIG: Record<string, any>;
  31. try {
  32. FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
  33. } catch (error: any) {
  34. console.warn(
  35. `Error JSON parsing firebase config. Supplied value: ${
  36. import.meta.env.VITE_APP_FIREBASE_CONFIG
  37. }`,
  38. );
  39. FIREBASE_CONFIG = {};
  40. }
  41. let firebasePromise: Promise<typeof import("firebase/app").default> | null =
  42. null;
  43. let firestorePromise: Promise<any> | null | true = null;
  44. let firebaseStoragePromise: Promise<any> | null | true = null;
  45. let isFirebaseInitialized = false;
  46. const _loadFirebase = async () => {
  47. const firebase = (
  48. await import(/* webpackChunkName: "firebase" */ "firebase/app")
  49. ).default;
  50. if (!isFirebaseInitialized) {
  51. try {
  52. firebase.initializeApp(FIREBASE_CONFIG);
  53. } catch (error: any) {
  54. // trying initialize again throws. Usually this is harmless, and happens
  55. // mainly in dev (HMR)
  56. if (error.code === "app/duplicate-app") {
  57. console.warn(error.name, error.code);
  58. } else {
  59. throw error;
  60. }
  61. }
  62. isFirebaseInitialized = true;
  63. }
  64. return firebase;
  65. };
  66. const _getFirebase = async (): Promise<
  67. typeof import("firebase/app").default
  68. > => {
  69. if (!firebasePromise) {
  70. firebasePromise = _loadFirebase();
  71. }
  72. return firebasePromise;
  73. };
  74. // -----------------------------------------------------------------------------
  75. const loadFirestore = async () => {
  76. const firebase = await _getFirebase();
  77. if (!firestorePromise) {
  78. firestorePromise = import(
  79. /* webpackChunkName: "firestore" */ "firebase/firestore"
  80. );
  81. }
  82. if (firestorePromise !== true) {
  83. await firestorePromise;
  84. firestorePromise = true;
  85. }
  86. return firebase;
  87. };
  88. export const loadFirebaseStorage = async () => {
  89. const firebase = await _getFirebase();
  90. if (!firebaseStoragePromise) {
  91. firebaseStoragePromise = import(
  92. /* webpackChunkName: "storage" */ "firebase/storage"
  93. );
  94. }
  95. if (firebaseStoragePromise !== true) {
  96. await firebaseStoragePromise;
  97. firebaseStoragePromise = true;
  98. }
  99. return firebase;
  100. };
  101. interface FirebaseStoredScene {
  102. sceneVersion: number;
  103. iv: firebase.default.firestore.Blob;
  104. ciphertext: firebase.default.firestore.Blob;
  105. }
  106. const encryptElements = async (
  107. key: string,
  108. elements: readonly ExcalidrawElement[],
  109. ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
  110. const json = JSON.stringify(elements);
  111. const encoded = new TextEncoder().encode(json);
  112. const { encryptedBuffer, iv } = await encryptData(key, encoded);
  113. return { ciphertext: encryptedBuffer, iv };
  114. };
  115. const decryptElements = async (
  116. data: FirebaseStoredScene,
  117. roomKey: string,
  118. ): Promise<readonly ExcalidrawElement[]> => {
  119. const ciphertext = data.ciphertext.toUint8Array();
  120. const iv = data.iv.toUint8Array();
  121. const decrypted = await decryptData(iv, ciphertext, roomKey);
  122. const decodedData = new TextDecoder("utf-8").decode(
  123. new Uint8Array(decrypted),
  124. );
  125. return JSON.parse(decodedData);
  126. };
  127. class FirebaseSceneVersionCache {
  128. private static cache = new WeakMap<Socket, number>();
  129. static get = (socket: Socket) => {
  130. return FirebaseSceneVersionCache.cache.get(socket);
  131. };
  132. static set = (
  133. socket: Socket,
  134. elements: readonly SyncableExcalidrawElement[],
  135. ) => {
  136. FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
  137. };
  138. }
  139. export const isSavedToFirebase = (
  140. portal: Portal,
  141. elements: readonly ExcalidrawElement[],
  142. ): boolean => {
  143. if (portal.socket && portal.roomId && portal.roomKey) {
  144. const sceneVersion = getSceneVersion(elements);
  145. return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
  146. }
  147. // if no room exists, consider the room saved so that we don't unnecessarily
  148. // prevent unload (there's nothing we could do at that point anyway)
  149. return true;
  150. };
  151. export const saveFilesToFirebase = async ({
  152. prefix,
  153. files,
  154. }: {
  155. prefix: string;
  156. files: { id: FileId; buffer: Uint8Array }[];
  157. }) => {
  158. const firebase = await loadFirebaseStorage();
  159. const erroredFiles = new Map<FileId, true>();
  160. const savedFiles = new Map<FileId, true>();
  161. await Promise.all(
  162. files.map(async ({ id, buffer }) => {
  163. try {
  164. await firebase
  165. .storage()
  166. .ref(`${prefix}/${id}`)
  167. .put(
  168. new Blob([buffer], {
  169. type: MIME_TYPES.binary,
  170. }),
  171. {
  172. cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
  173. },
  174. );
  175. savedFiles.set(id, true);
  176. } catch (error: any) {
  177. erroredFiles.set(id, true);
  178. }
  179. }),
  180. );
  181. return { savedFiles, erroredFiles };
  182. };
  183. const createFirebaseSceneDocument = async (
  184. firebase: ResolutionType<typeof loadFirestore>,
  185. elements: readonly SyncableExcalidrawElement[],
  186. roomKey: string,
  187. ) => {
  188. const sceneVersion = getSceneVersion(elements);
  189. const { ciphertext, iv } = await encryptElements(roomKey, elements);
  190. return {
  191. sceneVersion,
  192. ciphertext: firebase.firestore.Blob.fromUint8Array(
  193. new Uint8Array(ciphertext),
  194. ),
  195. iv: firebase.firestore.Blob.fromUint8Array(iv),
  196. } as FirebaseStoredScene;
  197. };
  198. export const saveToFirebase = async (
  199. portal: Portal,
  200. elements: readonly SyncableExcalidrawElement[],
  201. appState: AppState,
  202. ) => {
  203. const { roomId, roomKey, socket } = portal;
  204. if (
  205. // bail if no room exists as there's nothing we can do at this point
  206. !roomId ||
  207. !roomKey ||
  208. !socket ||
  209. isSavedToFirebase(portal, elements)
  210. ) {
  211. return null;
  212. }
  213. const firebase = await loadFirestore();
  214. const firestore = firebase.firestore();
  215. const docRef = firestore.collection("scenes").doc(roomId);
  216. const storedScene = await firestore.runTransaction(async (transaction) => {
  217. const snapshot = await transaction.get(docRef);
  218. if (!snapshot.exists) {
  219. const storedScene = await createFirebaseSceneDocument(
  220. firebase,
  221. elements,
  222. roomKey,
  223. );
  224. transaction.set(docRef, storedScene);
  225. return storedScene;
  226. }
  227. const prevStoredScene = snapshot.data() as FirebaseStoredScene;
  228. const prevStoredElements = getSyncableElements(
  229. restoreElements(await decryptElements(prevStoredScene, roomKey), null),
  230. );
  231. const reconciledElements = getSyncableElements(
  232. reconcileElements(
  233. elements,
  234. prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
  235. appState,
  236. ),
  237. );
  238. const storedScene = await createFirebaseSceneDocument(
  239. firebase,
  240. reconciledElements,
  241. roomKey,
  242. );
  243. transaction.update(docRef, storedScene);
  244. // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
  245. return storedScene;
  246. });
  247. const storedElements = getSyncableElements(
  248. restoreElements(await decryptElements(storedScene, roomKey), null),
  249. );
  250. FirebaseSceneVersionCache.set(socket, storedElements);
  251. return storedElements;
  252. };
  253. export const loadFromFirebase = async (
  254. roomId: string,
  255. roomKey: string,
  256. socket: Socket | null,
  257. ): Promise<readonly SyncableExcalidrawElement[] | null> => {
  258. const firebase = await loadFirestore();
  259. const db = firebase.firestore();
  260. const docRef = db.collection("scenes").doc(roomId);
  261. const doc = await docRef.get();
  262. if (!doc.exists) {
  263. return null;
  264. }
  265. const storedScene = doc.data() as FirebaseStoredScene;
  266. const elements = getSyncableElements(
  267. restoreElements(await decryptElements(storedScene, roomKey), null),
  268. );
  269. if (socket) {
  270. FirebaseSceneVersionCache.set(socket, elements);
  271. }
  272. return elements;
  273. };
  274. export const loadFilesFromFirebase = async (
  275. prefix: string,
  276. decryptionKey: string,
  277. filesIds: readonly FileId[],
  278. ) => {
  279. const loadedFiles: BinaryFileData[] = [];
  280. const erroredFiles = new Map<FileId, true>();
  281. await Promise.all(
  282. [...new Set(filesIds)].map(async (id) => {
  283. try {
  284. const url = `https://firebasestorage.googleapis.com/v0/b/${
  285. FIREBASE_CONFIG.storageBucket
  286. }/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
  287. const response = await fetch(`${url}?alt=media`);
  288. if (response.status < 400) {
  289. const arrayBuffer = await response.arrayBuffer();
  290. const { data, metadata } = await decompressData<BinaryFileMetadata>(
  291. new Uint8Array(arrayBuffer),
  292. {
  293. decryptionKey,
  294. },
  295. );
  296. const dataURL = new TextDecoder().decode(data) as DataURL;
  297. loadedFiles.push({
  298. mimeType: metadata.mimeType || MIME_TYPES.binary,
  299. id,
  300. dataURL,
  301. created: metadata?.created || Date.now(),
  302. lastRetrieved: metadata?.created || Date.now(),
  303. });
  304. } else {
  305. erroredFiles.set(id, true);
  306. }
  307. } catch (error: any) {
  308. erroredFiles.set(id, true);
  309. console.error(error);
  310. }
  311. }),
  312. );
  313. return { loadedFiles, erroredFiles };
  314. };