index.ts 9.7 KB

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