firebase.ts 9.9 KB

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