firebase.ts 9.9 KB

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