123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- import {
- compressData,
- decompressData,
- } from "../../packages/excalidraw/data/encode";
- import {
- decryptData,
- generateEncryptionKey,
- IV_LENGTH_BYTES,
- } from "../../packages/excalidraw/data/encryption";
- import { serializeAsJSON } from "../../packages/excalidraw/data/json";
- import { restore } from "../../packages/excalidraw/data/restore";
- import { ImportedDataState } from "../../packages/excalidraw/data/types";
- import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
- import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
- import {
- ExcalidrawElement,
- FileId,
- } from "../../packages/excalidraw/element/types";
- import { t } from "../../packages/excalidraw/i18n";
- import {
- AppState,
- BinaryFileData,
- BinaryFiles,
- UserIdleState,
- } from "../../packages/excalidraw/types";
- import { bytesToHexString } from "../../packages/excalidraw/utils";
- import {
- DELETED_ELEMENT_TIMEOUT,
- FILE_UPLOAD_MAX_BYTES,
- ROOM_ID_BYTES,
- } from "../app_constants";
- import { encodeFilesForUpload } from "./FileManager";
- import { saveFilesToFirebase } from "./firebase";
- export type SyncableExcalidrawElement = ExcalidrawElement & {
- _brand: "SyncableExcalidrawElement";
- };
- export const isSyncableElement = (
- element: ExcalidrawElement,
- ): element is SyncableExcalidrawElement => {
- if (element.isDeleted) {
- if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
- return true;
- }
- return false;
- }
- return !isInvisiblySmallElement(element);
- };
- export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
- elements.filter((element) =>
- isSyncableElement(element),
- ) as SyncableExcalidrawElement[];
- const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
- const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
- const generateRoomId = async () => {
- const buffer = new Uint8Array(ROOM_ID_BYTES);
- window.crypto.getRandomValues(buffer);
- return bytesToHexString(buffer);
- };
- /**
- * Right now the reason why we resolve connection params (url, polling...)
- * from upstream is to allow changing the params immediately when needed without
- * having to wait for clients to update the SW.
- *
- * If REACT_APP_WS_SERVER_URL env is set, we use that instead (useful for forks)
- */
- export const getCollabServer = async (): Promise<{
- url: string;
- polling: boolean;
- }> => {
- if (import.meta.env.VITE_APP_WS_SERVER_URL) {
- return {
- url: import.meta.env.VITE_APP_WS_SERVER_URL,
- polling: true,
- };
- }
- try {
- const resp = await fetch(
- `${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`,
- );
- return await resp.json();
- } catch (error) {
- console.error(error);
- throw new Error(t("errors.cannotResolveCollabServer"));
- }
- };
- export type EncryptedData = {
- data: ArrayBuffer;
- iv: Uint8Array;
- };
- export type SocketUpdateDataSource = {
- SCENE_INIT: {
- type: "SCENE_INIT";
- payload: {
- elements: readonly ExcalidrawElement[];
- };
- };
- SCENE_UPDATE: {
- type: "SCENE_UPDATE";
- payload: {
- elements: readonly ExcalidrawElement[];
- };
- };
- MOUSE_LOCATION: {
- type: "MOUSE_LOCATION";
- payload: {
- socketId: string;
- pointer: { x: number; y: number; tool: "pointer" | "laser" };
- button: "down" | "up";
- selectedElementIds: AppState["selectedElementIds"];
- username: string;
- };
- };
- IDLE_STATUS: {
- type: "IDLE_STATUS";
- payload: {
- socketId: string;
- userState: UserIdleState;
- username: string;
- };
- };
- };
- export type SocketUpdateDataIncoming =
- | SocketUpdateDataSource[keyof SocketUpdateDataSource]
- | {
- type: "INVALID_RESPONSE";
- };
- export type SocketUpdateData =
- SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
- _brand: "socketUpdateData";
- };
- const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
- export const isCollaborationLink = (link: string) => {
- const hash = new URL(link).hash;
- return RE_COLLAB_LINK.test(hash);
- };
- export const getCollaborationLinkData = (link: string) => {
- const hash = new URL(link).hash;
- const match = hash.match(RE_COLLAB_LINK);
- if (match && match[2].length !== 22) {
- window.alert(t("alerts.invalidEncryptionKey"));
- return null;
- }
- return match ? { roomId: match[1], roomKey: match[2] } : null;
- };
- export const generateCollaborationLinkData = async () => {
- const roomId = await generateRoomId();
- const roomKey = await generateEncryptionKey();
- if (!roomKey) {
- throw new Error("Couldn't generate room key");
- }
- return { roomId, roomKey };
- };
- export const getCollaborationLink = (data: {
- roomId: string;
- roomKey: string;
- }) => {
- return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
- };
- /**
- * Decodes shareLink data using the legacy buffer format.
- * @deprecated
- */
- const legacy_decodeFromBackend = async ({
- buffer,
- decryptionKey,
- }: {
- buffer: ArrayBuffer;
- decryptionKey: string;
- }) => {
- let decrypted: ArrayBuffer;
- try {
- // Buffer should contain both the IV (fixed length) and encrypted data
- const iv = buffer.slice(0, IV_LENGTH_BYTES);
- const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
- decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
- } catch (error: any) {
- // Fixed IV (old format, backward compatibility)
- const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
- decrypted = await decryptData(fixedIv, buffer, decryptionKey);
- }
- // We need to convert the decrypted array buffer to a string
- const string = new window.TextDecoder("utf-8").decode(
- new Uint8Array(decrypted),
- );
- const data: ImportedDataState = JSON.parse(string);
- return {
- elements: data.elements || null,
- appState: data.appState || null,
- };
- };
- const importFromBackend = async (
- id: string,
- decryptionKey: string,
- ): Promise<ImportedDataState> => {
- try {
- const response = await fetch(`${BACKEND_V2_GET}${id}`);
- if (!response.ok) {
- window.alert(t("alerts.importBackendFailed"));
- return {};
- }
- const buffer = await response.arrayBuffer();
- try {
- const { data: decodedBuffer } = await decompressData(
- new Uint8Array(buffer),
- {
- decryptionKey,
- },
- );
- const data: ImportedDataState = JSON.parse(
- new TextDecoder().decode(decodedBuffer),
- );
- return {
- elements: data.elements || null,
- appState: data.appState || null,
- };
- } catch (error: any) {
- console.warn(
- "error when decoding shareLink data using the new format:",
- error,
- );
- return legacy_decodeFromBackend({ buffer, decryptionKey });
- }
- } catch (error: any) {
- window.alert(t("alerts.importBackendFailed"));
- console.error(error);
- return {};
- }
- };
- export const loadScene = async (
- id: string | null,
- privateKey: string | null,
- // Supply local state even if importing from backend to ensure we restore
- // localStorage user settings which we do not persist on server.
- // Non-optional so we don't forget to pass it even if `undefined`.
- localDataState: ImportedDataState | undefined | null,
- ) => {
- let data;
- if (id != null && privateKey != null) {
- // the private key is used to decrypt the content from the server, take
- // extra care not to leak it
- data = restore(
- await importFromBackend(id, privateKey),
- localDataState?.appState,
- localDataState?.elements,
- { repairBindings: true, refreshDimensions: false },
- );
- } else {
- data = restore(localDataState || null, null, null, {
- repairBindings: true,
- });
- }
- return {
- elements: data.elements,
- appState: data.appState,
- // note: this will always be empty because we're not storing files
- // in the scene database/localStorage, and instead fetch them async
- // from a different database
- files: data.files,
- commitToHistory: false,
- };
- };
- type ExportToBackendResult =
- | { url: null; errorMessage: string }
- | { url: string; errorMessage: null };
- export const exportToBackend = async (
- elements: readonly ExcalidrawElement[],
- appState: Partial<AppState>,
- files: BinaryFiles,
- ): Promise<ExportToBackendResult> => {
- const encryptionKey = await generateEncryptionKey("string");
- const payload = await compressData(
- new TextEncoder().encode(
- serializeAsJSON(elements, appState, files, "database"),
- ),
- { encryptionKey },
- );
- try {
- const filesMap = new Map<FileId, BinaryFileData>();
- for (const element of elements) {
- if (isInitializedImageElement(element) && files[element.fileId]) {
- filesMap.set(element.fileId, files[element.fileId]);
- }
- }
- const filesToUpload = await encodeFilesForUpload({
- files: filesMap,
- encryptionKey,
- maxBytes: FILE_UPLOAD_MAX_BYTES,
- });
- const response = await fetch(BACKEND_V2_POST, {
- method: "POST",
- body: payload.buffer,
- });
- const json = await response.json();
- if (json.id) {
- const url = new URL(window.location.href);
- // We need to store the key (and less importantly the id) as hash instead
- // of queryParam in order to never send it to the server
- url.hash = `json=${json.id},${encryptionKey}`;
- const urlString = url.toString();
- await saveFilesToFirebase({
- prefix: `/files/shareLinks/${json.id}`,
- files: filesToUpload,
- });
- return { url: urlString, errorMessage: null };
- } else if (json.error_class === "RequestTooLargeError") {
- return {
- url: null,
- errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
- };
- }
- return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
- } catch (error: any) {
- console.error(error);
- return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
- }
- };
|