index.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import {
  2. compressData,
  3. decompressData,
  4. } from "@excalidraw/excalidraw/data/encode";
  5. import {
  6. decryptData,
  7. generateEncryptionKey,
  8. IV_LENGTH_BYTES,
  9. } from "@excalidraw/excalidraw/data/encryption";
  10. import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
  11. import { isInvisiblySmallElement } from "@excalidraw/element";
  12. import { isInitializedImageElement } from "@excalidraw/element";
  13. import { t } from "@excalidraw/excalidraw/i18n";
  14. import { bytesToHexString } from "@excalidraw/common";
  15. import type { UserIdleState } from "@excalidraw/common";
  16. import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
  17. import type { SceneBounds } from "@excalidraw/element";
  18. import type {
  19. ExcalidrawElement,
  20. FileId,
  21. OrderedExcalidrawElement,
  22. } from "@excalidraw/element/types";
  23. import type {
  24. AppState,
  25. BinaryFileData,
  26. BinaryFiles,
  27. SocketId,
  28. } from "@excalidraw/excalidraw/types";
  29. import type { MakeBrand } from "@excalidraw/common/utility-types";
  30. import {
  31. DELETED_ELEMENT_TIMEOUT,
  32. FILE_UPLOAD_MAX_BYTES,
  33. ROOM_ID_BYTES,
  34. } from "../app_constants";
  35. import { encodeFilesForUpload } from "./FileManager";
  36. import { saveFilesToFirebase } from "./firebase";
  37. import type { WS_SUBTYPES } from "../app_constants";
  38. export type SyncableExcalidrawElement = OrderedExcalidrawElement &
  39. MakeBrand<"SyncableExcalidrawElement">;
  40. export const isSyncableElement = (
  41. element: OrderedExcalidrawElement,
  42. ): element is SyncableExcalidrawElement => {
  43. if (element.isDeleted) {
  44. if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
  45. return true;
  46. }
  47. return false;
  48. }
  49. return !isInvisiblySmallElement(element);
  50. };
  51. export const getSyncableElements = (
  52. elements: readonly OrderedExcalidrawElement[],
  53. ) =>
  54. elements.filter((element) =>
  55. isSyncableElement(element),
  56. ) as SyncableExcalidrawElement[];
  57. const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
  58. const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
  59. const generateRoomId = async () => {
  60. const buffer = new Uint8Array(ROOM_ID_BYTES);
  61. window.crypto.getRandomValues(buffer);
  62. return bytesToHexString(buffer);
  63. };
  64. export type EncryptedData = {
  65. data: ArrayBuffer;
  66. iv: Uint8Array;
  67. };
  68. export type SocketUpdateDataSource = {
  69. INVALID_RESPONSE: {
  70. type: WS_SUBTYPES.INVALID_RESPONSE;
  71. };
  72. SCENE_INIT: {
  73. type: WS_SUBTYPES.INIT;
  74. payload: {
  75. elements: readonly OrderedExcalidrawElement[];
  76. };
  77. };
  78. SCENE_UPDATE: {
  79. type: WS_SUBTYPES.UPDATE;
  80. payload: {
  81. elements: readonly OrderedExcalidrawElement[];
  82. };
  83. };
  84. MOUSE_LOCATION: {
  85. type: WS_SUBTYPES.MOUSE_LOCATION;
  86. payload: {
  87. socketId: SocketId;
  88. pointer: { x: number; y: number; tool: "pointer" | "laser" };
  89. button: "down" | "up";
  90. selectedElementIds: AppState["selectedElementIds"];
  91. username: string;
  92. };
  93. };
  94. USER_VISIBLE_SCENE_BOUNDS: {
  95. type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
  96. payload: {
  97. socketId: SocketId;
  98. username: string;
  99. sceneBounds: SceneBounds;
  100. };
  101. };
  102. IDLE_STATUS: {
  103. type: WS_SUBTYPES.IDLE_STATUS;
  104. payload: {
  105. socketId: SocketId;
  106. userState: UserIdleState;
  107. username: string;
  108. };
  109. };
  110. };
  111. export type SocketUpdateDataIncoming =
  112. SocketUpdateDataSource[keyof SocketUpdateDataSource];
  113. export type SocketUpdateData =
  114. SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
  115. _brand: "socketUpdateData";
  116. };
  117. const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
  118. export const isCollaborationLink = (link: string) => {
  119. const hash = new URL(link).hash;
  120. return RE_COLLAB_LINK.test(hash);
  121. };
  122. export const getCollaborationLinkData = (link: string) => {
  123. const hash = new URL(link).hash;
  124. const match = hash.match(RE_COLLAB_LINK);
  125. if (match && match[2].length !== 22) {
  126. window.alert(t("alerts.invalidEncryptionKey"));
  127. return null;
  128. }
  129. return match ? { roomId: match[1], roomKey: match[2] } : null;
  130. };
  131. export const generateCollaborationLinkData = async () => {
  132. const roomId = await generateRoomId();
  133. const roomKey = await generateEncryptionKey();
  134. if (!roomKey) {
  135. throw new Error("Couldn't generate room key");
  136. }
  137. return { roomId, roomKey };
  138. };
  139. export const getCollaborationLink = (data: {
  140. roomId: string;
  141. roomKey: string;
  142. }) => {
  143. return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
  144. };
  145. /**
  146. * Decodes shareLink data using the legacy buffer format.
  147. * @deprecated
  148. */
  149. const legacy_decodeFromBackend = async ({
  150. buffer,
  151. decryptionKey,
  152. }: {
  153. buffer: ArrayBuffer;
  154. decryptionKey: string;
  155. }) => {
  156. let decrypted: ArrayBuffer;
  157. try {
  158. // Buffer should contain both the IV (fixed length) and encrypted data
  159. const iv = buffer.slice(0, IV_LENGTH_BYTES);
  160. const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
  161. decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
  162. } catch (error: any) {
  163. // Fixed IV (old format, backward compatibility)
  164. const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
  165. decrypted = await decryptData(fixedIv, buffer, decryptionKey);
  166. }
  167. // We need to convert the decrypted array buffer to a string
  168. const string = new window.TextDecoder("utf-8").decode(
  169. new Uint8Array(decrypted),
  170. );
  171. const data: ImportedDataState = JSON.parse(string);
  172. return {
  173. elements: data.elements || null,
  174. appState: data.appState || null,
  175. };
  176. };
  177. export const importFromBackend = async (
  178. id: string,
  179. decryptionKey: string,
  180. ): Promise<ImportedDataState> => {
  181. try {
  182. const response = await fetch(`${BACKEND_V2_GET}${id}`);
  183. if (!response.ok) {
  184. window.alert(t("alerts.importBackendFailed"));
  185. return {};
  186. }
  187. const buffer = await response.arrayBuffer();
  188. try {
  189. const { data: decodedBuffer } = await decompressData(
  190. new Uint8Array(buffer),
  191. {
  192. decryptionKey,
  193. },
  194. );
  195. const data: ImportedDataState = JSON.parse(
  196. new TextDecoder().decode(decodedBuffer),
  197. );
  198. return {
  199. elements: data.elements || null,
  200. appState: data.appState || null,
  201. };
  202. } catch (error: any) {
  203. console.warn(
  204. "error when decoding shareLink data using the new format:",
  205. error,
  206. );
  207. return legacy_decodeFromBackend({ buffer, decryptionKey });
  208. }
  209. } catch (error: any) {
  210. window.alert(t("alerts.importBackendFailed"));
  211. console.error(error);
  212. return {};
  213. }
  214. };
  215. type ExportToBackendResult =
  216. | { url: null; errorMessage: string }
  217. | { url: string; errorMessage: null };
  218. export const exportToBackend = async (
  219. elements: readonly ExcalidrawElement[],
  220. appState: Partial<AppState>,
  221. files: BinaryFiles,
  222. ): Promise<ExportToBackendResult> => {
  223. const encryptionKey = await generateEncryptionKey("string");
  224. const payload = await compressData(
  225. new TextEncoder().encode(
  226. serializeAsJSON(elements, appState, files, "database"),
  227. ),
  228. { encryptionKey },
  229. );
  230. try {
  231. const filesMap = new Map<FileId, BinaryFileData>();
  232. for (const element of elements) {
  233. if (isInitializedImageElement(element) && files[element.fileId]) {
  234. filesMap.set(element.fileId, files[element.fileId]);
  235. }
  236. }
  237. const filesToUpload = await encodeFilesForUpload({
  238. files: filesMap,
  239. encryptionKey,
  240. maxBytes: FILE_UPLOAD_MAX_BYTES,
  241. });
  242. const response = await fetch(BACKEND_V2_POST, {
  243. method: "POST",
  244. body: payload.buffer,
  245. });
  246. const json = await response.json();
  247. if (json.id) {
  248. const url = new URL(window.location.href);
  249. // We need to store the key (and less importantly the id) as hash instead
  250. // of queryParam in order to never send it to the server
  251. url.hash = `json=${json.id},${encryptionKey}`;
  252. const urlString = url.toString();
  253. await saveFilesToFirebase({
  254. prefix: `/files/shareLinks/${json.id}`,
  255. files: filesToUpload,
  256. });
  257. return { url: urlString, errorMessage: null };
  258. } else if (json.error_class === "RequestTooLargeError") {
  259. return {
  260. url: null,
  261. errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
  262. };
  263. }
  264. return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
  265. } catch (error: any) {
  266. console.error(error);
  267. return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
  268. }
  269. };