mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-10 11:35:52 +01:00
d426cc968d
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
337 lines
9.3 KiB
TypeScript
337 lines
9.3 KiB
TypeScript
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 { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
|
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,
|
|
SocketId,
|
|
UserIdleState,
|
|
} from "../../packages/excalidraw/types";
|
|
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
|
import {
|
|
DELETED_ELEMENT_TIMEOUT,
|
|
FILE_UPLOAD_MAX_BYTES,
|
|
ROOM_ID_BYTES,
|
|
WS_SUBTYPES,
|
|
} 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);
|
|
};
|
|
|
|
export type EncryptedData = {
|
|
data: ArrayBuffer;
|
|
iv: Uint8Array;
|
|
};
|
|
|
|
export type SocketUpdateDataSource = {
|
|
INVALID_RESPONSE: {
|
|
type: WS_SUBTYPES.INVALID_RESPONSE;
|
|
};
|
|
SCENE_INIT: {
|
|
type: WS_SUBTYPES.INIT;
|
|
payload: {
|
|
elements: readonly ExcalidrawElement[];
|
|
};
|
|
};
|
|
SCENE_UPDATE: {
|
|
type: WS_SUBTYPES.UPDATE;
|
|
payload: {
|
|
elements: readonly ExcalidrawElement[];
|
|
};
|
|
};
|
|
MOUSE_LOCATION: {
|
|
type: WS_SUBTYPES.MOUSE_LOCATION;
|
|
payload: {
|
|
socketId: SocketId;
|
|
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
|
button: "down" | "up";
|
|
selectedElementIds: AppState["selectedElementIds"];
|
|
username: string;
|
|
};
|
|
};
|
|
USER_VISIBLE_SCENE_BOUNDS: {
|
|
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
|
|
payload: {
|
|
socketId: SocketId;
|
|
username: string;
|
|
sceneBounds: SceneBounds;
|
|
};
|
|
};
|
|
IDLE_STATUS: {
|
|
type: WS_SUBTYPES.IDLE_STATUS;
|
|
payload: {
|
|
socketId: SocketId;
|
|
userState: UserIdleState;
|
|
username: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
export type SocketUpdateDataIncoming =
|
|
SocketUpdateDataSource[keyof SocketUpdateDataSource];
|
|
|
|
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") };
|
|
}
|
|
};
|