index.ts 9.6 KB

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