index.ts 10 KB

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