firebase.ts 9.6 KB

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