firebase.ts 9.6 KB

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