index.ts 9.4 KB

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