firebase.ts 8.6 KB

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