2024-01-01 18:05:48 +01:00
|
|
|
// Inspired and partly copied from https://gitlab.com/kiliandeca/excalidraw-fork
|
|
|
|
// MIT, Kilian Decaderincourt
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
import type { SyncableExcalidrawElement } from ".";
|
|
|
|
import { getSyncableElements } from ".";
|
2024-01-01 18:05:48 +01:00
|
|
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
|
|
|
import { decompressData } from "../../packages/excalidraw/data/encode";
|
2024-08-23 15:54:59 +02:00
|
|
|
import {
|
|
|
|
encryptData,
|
|
|
|
decryptData,
|
|
|
|
IV_LENGTH_BYTES,
|
|
|
|
} from "../../packages/excalidraw/data/encryption";
|
2024-01-01 18:05:48 +01:00
|
|
|
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
|
|
|
import { getSceneVersion } from "../../packages/excalidraw/element";
|
2024-08-23 15:54:59 +02:00
|
|
|
import type {
|
|
|
|
ExcalidrawElement,
|
|
|
|
FileId,
|
|
|
|
OrderedExcalidrawElement,
|
|
|
|
} from "../../packages/excalidraw/element/types";
|
|
|
|
import type {
|
2024-01-01 18:05:48 +01:00
|
|
|
AppState,
|
|
|
|
BinaryFileData,
|
|
|
|
BinaryFileMetadata,
|
|
|
|
DataURL,
|
|
|
|
} from "../../packages/excalidraw/types";
|
2024-08-23 15:54:59 +02:00
|
|
|
import type Portal from "../collab/Portal";
|
|
|
|
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
|
|
|
import { reconcileElements } from "../../packages/excalidraw/data/reconcile";
|
|
|
|
import type { StoredScene } from "./StorageBackend";
|
2024-05-27 16:11:57 +02:00
|
|
|
import type { Socket } from "socket.io-client";
|
2024-01-01 18:05:48 +01:00
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const HTTP_STORAGE_BACKEND_URL = import.meta.env
|
|
|
|
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
|
|
|
|
const SCENE_VERSION_LENGTH_BYTES = 4;
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
// There is a lot of intentional duplication with the firebase file
|
|
|
|
// to prevent modifying upstream files and ease futur maintenance of this fork
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const httpStorageSceneVersionCache = new WeakMap<Socket, number>();
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
export const isSavedToHttpStorage = (
|
|
|
|
portal: Portal,
|
|
|
|
elements: readonly ExcalidrawElement[],
|
|
|
|
): boolean => {
|
|
|
|
if (portal.socket && portal.roomId && portal.roomKey) {
|
|
|
|
const sceneVersion = getSceneVersion(elements);
|
|
|
|
|
|
|
|
return httpStorageSceneVersionCache.get(portal.socket) === sceneVersion;
|
|
|
|
}
|
|
|
|
// if no room exists, consider the room saved so that we don't unnecessarily
|
|
|
|
// prevent unload (there's nothing we could do at that point anyway)
|
|
|
|
return true;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const saveToHttpStorage = async (
|
|
|
|
portal: Portal,
|
|
|
|
elements: readonly SyncableExcalidrawElement[],
|
|
|
|
appState: AppState,
|
|
|
|
) => {
|
|
|
|
const { roomId, roomKey, socket } = portal;
|
|
|
|
if (
|
|
|
|
// if no room exists, consider the room saved because there's nothing we can
|
|
|
|
// do at this point
|
|
|
|
!roomId ||
|
|
|
|
!roomKey ||
|
|
|
|
!socket ||
|
|
|
|
isSavedToHttpStorage(portal, elements)
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const sceneVersion = getSceneVersion(elements);
|
|
|
|
const getResponse = await fetch(
|
|
|
|
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!getResponse.ok && getResponse.status !== 404) {
|
|
|
|
return false;
|
2024-08-23 15:54:59 +02:00
|
|
|
}
|
2024-01-01 18:05:48 +01:00
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
if (getResponse.status === 404) {
|
|
|
|
const result: boolean = await saveElementsToBackend(
|
|
|
|
roomKey,
|
|
|
|
roomId,
|
|
|
|
[...elements],
|
|
|
|
sceneVersion,
|
|
|
|
);
|
|
|
|
if (result) {
|
|
|
|
return null;
|
2024-01-01 18:05:48 +01:00
|
|
|
}
|
2024-08-23 15:54:59 +02:00
|
|
|
return false;
|
|
|
|
}
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
// If room already exist, we compare scene versions to check
|
|
|
|
// if we're up to date before saving our scene
|
|
|
|
const buffer = await getResponse.arrayBuffer();
|
|
|
|
const sceneVersionFromRequest = parseSceneVersionFromRequest(buffer);
|
|
|
|
if (sceneVersionFromRequest >= sceneVersion) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const existingElements = await getElementsFromBuffer(buffer, roomKey);
|
|
|
|
const reconciledElements = getSyncableElements(
|
2024-08-23 15:54:59 +02:00
|
|
|
reconcileElements(
|
|
|
|
elements,
|
|
|
|
existingElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
|
|
|
appState,
|
|
|
|
),
|
2024-01-01 18:05:48 +01:00
|
|
|
);
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const result: boolean = await saveElementsToBackend(
|
|
|
|
roomKey,
|
|
|
|
roomId,
|
|
|
|
reconciledElements,
|
|
|
|
sceneVersion,
|
|
|
|
);
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
if (result) {
|
|
|
|
httpStorageSceneVersionCache.set(socket, sceneVersion);
|
2024-08-23 15:54:59 +02:00
|
|
|
return elements;
|
2024-01-01 18:05:48 +01:00
|
|
|
}
|
|
|
|
return false;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const loadFromHttpStorage = async (
|
|
|
|
roomId: string,
|
|
|
|
roomKey: string,
|
2024-05-27 16:11:57 +02:00
|
|
|
socket: Socket | null,
|
2024-01-01 18:05:48 +01:00
|
|
|
): Promise<readonly ExcalidrawElement[] | null> => {
|
2024-08-23 15:54:59 +02:00
|
|
|
const HTTP_STORAGE_BACKEND_URL = import.meta.env
|
|
|
|
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
|
2024-01-01 18:05:48 +01:00
|
|
|
const getResponse = await fetch(
|
|
|
|
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
|
|
|
|
);
|
|
|
|
|
|
|
|
const buffer = await getResponse.arrayBuffer();
|
|
|
|
const elements = await getElementsFromBuffer(buffer, roomKey);
|
|
|
|
|
|
|
|
if (socket) {
|
|
|
|
httpStorageSceneVersionCache.set(socket, getSceneVersion(elements));
|
|
|
|
}
|
|
|
|
|
|
|
|
return restoreElements(elements, null);
|
|
|
|
};
|
|
|
|
|
|
|
|
const getElementsFromBuffer = async (
|
|
|
|
buffer: ArrayBuffer,
|
|
|
|
key: string,
|
|
|
|
): Promise<readonly ExcalidrawElement[]> => {
|
|
|
|
// Buffer should contain both the IV (fixed length) and encrypted data
|
|
|
|
const sceneVersion = parseSceneVersionFromRequest(buffer);
|
2024-08-23 15:54:59 +02:00
|
|
|
const iv = new Uint8Array(
|
|
|
|
buffer.slice(
|
|
|
|
SCENE_VERSION_LENGTH_BYTES,
|
|
|
|
IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
const encrypted = buffer.slice(
|
|
|
|
IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES,
|
|
|
|
buffer.byteLength,
|
|
|
|
);
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
return await decryptElements(
|
2024-08-23 15:54:59 +02:00
|
|
|
{ sceneVersion, ciphertext: encrypted, iv },
|
|
|
|
key,
|
2024-01-01 18:05:48 +01:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export const saveFilesToHttpStorage = async ({
|
|
|
|
prefix,
|
|
|
|
files,
|
|
|
|
}: {
|
|
|
|
prefix: string;
|
|
|
|
files: { id: FileId; buffer: Uint8Array }[];
|
|
|
|
}) => {
|
|
|
|
const erroredFiles = new Map<FileId, true>();
|
|
|
|
const savedFiles = new Map<FileId, true>();
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const HTTP_STORAGE_BACKEND_URL = import.meta.env
|
|
|
|
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
files.map(async ({ id, buffer }) => {
|
|
|
|
try {
|
|
|
|
const payloadBlob = new Blob([buffer]);
|
|
|
|
const payload = await new Response(payloadBlob).arrayBuffer();
|
|
|
|
await fetch(`${HTTP_STORAGE_BACKEND_URL}/files/${id}`, {
|
|
|
|
method: "PUT",
|
|
|
|
body: payload,
|
|
|
|
});
|
|
|
|
savedFiles.set(id, true);
|
|
|
|
} catch (error: any) {
|
|
|
|
erroredFiles.set(id, true);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
|
|
|
|
return { savedFiles, erroredFiles };
|
|
|
|
};
|
|
|
|
|
|
|
|
export const loadFilesFromHttpStorage = async (
|
|
|
|
prefix: string,
|
|
|
|
decryptionKey: string,
|
|
|
|
filesIds: readonly FileId[],
|
|
|
|
) => {
|
|
|
|
const loadedFiles: BinaryFileData[] = [];
|
|
|
|
const erroredFiles = new Map<FileId, true>();
|
|
|
|
|
|
|
|
//////////////
|
|
|
|
await Promise.all(
|
|
|
|
[...new Set(filesIds)].map(async (id) => {
|
|
|
|
try {
|
2024-08-23 15:54:59 +02:00
|
|
|
const HTTP_STORAGE_BACKEND_URL = import.meta.env
|
|
|
|
.VITE_APP_HTTP_STORAGE_BACKEND_URL;
|
2024-01-01 18:05:48 +01:00
|
|
|
const response = await fetch(`${HTTP_STORAGE_BACKEND_URL}/files/${id}`);
|
|
|
|
if (response.status < 400) {
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
|
|
|
|
|
|
const { data, metadata } = await decompressData<BinaryFileMetadata>(
|
|
|
|
new Uint8Array(arrayBuffer),
|
|
|
|
{
|
|
|
|
decryptionKey,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
const dataURL = new TextDecoder().decode(data) as DataURL;
|
|
|
|
|
|
|
|
loadedFiles.push({
|
|
|
|
mimeType: metadata.mimeType || MIME_TYPES.binary,
|
|
|
|
id,
|
|
|
|
dataURL,
|
|
|
|
created: metadata?.created || Date.now(),
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
erroredFiles.set(id, true);
|
|
|
|
}
|
|
|
|
} catch (error: any) {
|
|
|
|
erroredFiles.set(id, true);
|
|
|
|
console.error(error);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
//////
|
|
|
|
|
|
|
|
return { loadedFiles, erroredFiles };
|
|
|
|
};
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
const saveElementsToBackend = async (
|
|
|
|
roomKey: string,
|
|
|
|
roomId: string,
|
|
|
|
elements: SyncableExcalidrawElement[],
|
|
|
|
sceneVersion: number,
|
|
|
|
) => {
|
2024-01-01 18:05:48 +01:00
|
|
|
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
|
|
|
|
|
|
|
// Concatenate Scene Version, IV with encrypted data (IV does not have to be secret).
|
|
|
|
const numberBuffer = new ArrayBuffer(4);
|
|
|
|
const numberView = new DataView(numberBuffer);
|
|
|
|
numberView.setUint32(0, sceneVersion, false);
|
|
|
|
const sceneVersionBuffer = numberView.buffer;
|
2024-08-23 15:54:59 +02:00
|
|
|
const payloadBlob = await new Response(
|
|
|
|
new Blob([sceneVersionBuffer, iv.buffer, ciphertext]),
|
|
|
|
).arrayBuffer();
|
2024-01-01 18:05:48 +01:00
|
|
|
const putResponse = await fetch(
|
|
|
|
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
|
|
|
|
{
|
|
|
|
method: "PUT",
|
|
|
|
body: payloadBlob,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
2024-08-23 15:54:59 +02:00
|
|
|
return putResponse.ok;
|
|
|
|
};
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
const parseSceneVersionFromRequest = (buffer: ArrayBuffer) => {
|
|
|
|
const view = new DataView(buffer);
|
|
|
|
return view.getUint32(0, false);
|
2024-08-23 15:54:59 +02:00
|
|
|
};
|
2024-01-01 18:05:48 +01:00
|
|
|
|
|
|
|
const decryptElements = async (
|
|
|
|
data: StoredScene,
|
|
|
|
roomKey: string,
|
|
|
|
): Promise<readonly ExcalidrawElement[]> => {
|
|
|
|
const ciphertext = data.ciphertext;
|
|
|
|
const iv = data.iv;
|
|
|
|
|
|
|
|
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
|
|
|
const decodedData = new TextDecoder("utf-8").decode(
|
|
|
|
new Uint8Array(decrypted),
|
|
|
|
);
|
|
|
|
return JSON.parse(decodedData);
|
|
|
|
};
|
|
|
|
|
|
|
|
const encryptElements = async (
|
|
|
|
key: string,
|
|
|
|
elements: readonly ExcalidrawElement[],
|
|
|
|
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
|
|
|
|
const json = JSON.stringify(elements);
|
|
|
|
const encoded = new TextEncoder().encode(json);
|
|
|
|
const { encryptedBuffer, iv } = await encryptData(key, encoded);
|
|
|
|
|
|
|
|
return { ciphertext: encryptedBuffer, iv };
|
2024-08-23 15:54:59 +02:00
|
|
|
};
|