firebase.ts 9.4 KB

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