Factor out collaboration code (#2313)
Co-authored-by: Lipis <lipiridis@gmail.com> Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
164
src/excalidraw-app/data/firebase.ts
Normal file
164
src/excalidraw-app/data/firebase.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { getImportedKey } from "../data";
|
||||
import { createIV } from "./index";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { getSceneVersion } from "../../element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../data/restore";
|
||||
|
||||
let firebasePromise: Promise<
|
||||
typeof import("firebase/app").default
|
||||
> | null = null;
|
||||
|
||||
const loadFirebase = async () => {
|
||||
const firebase = (
|
||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||
).default;
|
||||
await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
|
||||
|
||||
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
|
||||
return firebase;
|
||||
};
|
||||
|
||||
const getFirebase = async (): Promise<
|
||||
typeof import("firebase/app").default
|
||||
> => {
|
||||
if (!firebasePromise) {
|
||||
firebasePromise = loadFirebase();
|
||||
}
|
||||
return await firebasePromise!;
|
||||
};
|
||||
|
||||
interface FirebaseStoredScene {
|
||||
sceneVersion: number;
|
||||
iv: firebase.default.firestore.Blob;
|
||||
ciphertext: firebase.default.firestore.Blob;
|
||||
}
|
||||
|
||||
const encryptElements = async (
|
||||
key: string,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
|
||||
const importedKey = await getImportedKey(key, "encrypt");
|
||||
const iv = createIV();
|
||||
const json = JSON.stringify(elements);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
importedKey,
|
||||
encoded,
|
||||
);
|
||||
|
||||
return { ciphertext, iv };
|
||||
};
|
||||
|
||||
const decryptElements = async (
|
||||
key: string,
|
||||
iv: Uint8Array,
|
||||
ciphertext: ArrayBuffer,
|
||||
): Promise<readonly ExcalidrawElement[]> => {
|
||||
const importedKey = await getImportedKey(key, "decrypt");
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
importedKey,
|
||||
ciphertext,
|
||||
);
|
||||
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted) as any,
|
||||
);
|
||||
return JSON.parse(decodedData);
|
||||
};
|
||||
|
||||
const firebaseSceneVersionCache = new WeakMap<SocketIOClient.Socket, number>();
|
||||
|
||||
export const isSavedToFirebase = (
|
||||
portal: Portal,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): boolean => {
|
||||
if (portal.socket && portal.roomId && portal.roomKey) {
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
return firebaseSceneVersionCache.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 saveToFirebase = async (
|
||||
portal: Portal,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
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 ||
|
||||
isSavedToFirebase(portal, elements)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const firebase = await getFirebase();
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
|
||||
const nextDocData = {
|
||||
sceneVersion,
|
||||
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||
new Uint8Array(ciphertext),
|
||||
),
|
||||
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
|
||||
const db = firebase.firestore();
|
||||
const docRef = db.collection("scenes").doc(roomId);
|
||||
const didUpdate = await db.runTransaction(async (transaction) => {
|
||||
const doc = await transaction.get(docRef);
|
||||
if (!doc.exists) {
|
||||
transaction.set(docRef, nextDocData);
|
||||
return true;
|
||||
}
|
||||
|
||||
const prevDocData = doc.data() as FirebaseStoredScene;
|
||||
if (prevDocData.sceneVersion >= nextDocData.sceneVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
transaction.update(docRef, nextDocData);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (didUpdate) {
|
||||
firebaseSceneVersionCache.set(socket, sceneVersion);
|
||||
}
|
||||
|
||||
return didUpdate;
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||
const firebase = await getFirebase();
|
||||
const db = firebase.firestore();
|
||||
|
||||
const docRef = db.collection("scenes").doc(roomId);
|
||||
const doc = await docRef.get();
|
||||
if (!doc.exists) {
|
||||
return null;
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const ciphertext = storedScene.ciphertext.toUint8Array();
|
||||
const iv = storedScene.iv.toUint8Array();
|
||||
return restoreElements(await decryptElements(roomKey, iv, ciphertext));
|
||||
};
|
230
src/excalidraw-app/data/index.ts
Normal file
230
src/excalidraw-app/data/index.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { t } from "../../i18n";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { restore } from "../../data/restore";
|
||||
import { EVENT_ACTION, trackEvent } from "../../analytics";
|
||||
|
||||
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||
|
||||
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
|
||||
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
||||
|
||||
const generateRandomID = async () => {
|
||||
const arr = new Uint8Array(10);
|
||||
window.crypto.getRandomValues(arr);
|
||||
return Array.from(arr, byteToHex).join("");
|
||||
};
|
||||
|
||||
const generateEncryptionKey = async () => {
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 128,
|
||||
},
|
||||
true, // extractable
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
return (await window.crypto.subtle.exportKey("jwk", key)).k;
|
||||
};
|
||||
|
||||
export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
|
||||
|
||||
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 };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SocketUpdateDataIncoming =
|
||||
| SocketUpdateDataSource[keyof SocketUpdateDataSource]
|
||||
| {
|
||||
type: "INVALID_RESPONSE";
|
||||
};
|
||||
|
||||
export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
|
||||
_brand: "socketUpdateData";
|
||||
};
|
||||
|
||||
export const createIV = () => {
|
||||
const arr = new Uint8Array(12);
|
||||
return window.crypto.getRandomValues(arr);
|
||||
};
|
||||
|
||||
export const encryptAESGEM = async (
|
||||
data: Uint8Array,
|
||||
key: string,
|
||||
): Promise<EncryptedData> => {
|
||||
const importedKey = await getImportedKey(key, "encrypt");
|
||||
const iv = createIV();
|
||||
return {
|
||||
data: await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
importedKey,
|
||||
data,
|
||||
),
|
||||
iv,
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptAESGEM = async (
|
||||
data: ArrayBuffer,
|
||||
key: string,
|
||||
iv: Uint8Array,
|
||||
): Promise<SocketUpdateDataIncoming> => {
|
||||
try {
|
||||
const importedKey = await getImportedKey(key, "decrypt");
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
importedKey,
|
||||
data,
|
||||
);
|
||||
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted) as any,
|
||||
);
|
||||
return JSON.parse(decodedData);
|
||||
} catch (error) {
|
||||
window.alert(t("alerts.decryptFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
return {
|
||||
type: "INVALID_RESPONSE",
|
||||
};
|
||||
};
|
||||
|
||||
export const getCollaborationLinkData = (link: string) => {
|
||||
if (link.length === 0) {
|
||||
return;
|
||||
}
|
||||
const hash = new URL(link).hash;
|
||||
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
||||
};
|
||||
|
||||
export const generateCollaborationLink = async () => {
|
||||
const id = await generateRandomID();
|
||||
const key = await generateEncryptionKey();
|
||||
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
|
||||
};
|
||||
|
||||
export const getImportedKey = (key: string, usage: KeyUsage) =>
|
||||
window.crypto.subtle.importKey(
|
||||
"jwk",
|
||||
{
|
||||
alg: "A128GCM",
|
||||
ext: true,
|
||||
k: key,
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
kty: "oct",
|
||||
},
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 128,
|
||||
},
|
||||
false, // extractable
|
||||
[usage],
|
||||
);
|
||||
|
||||
const importFromBackend = async (
|
||||
id: string | null,
|
||||
privateKey?: string | null,
|
||||
): Promise<ImportedDataState> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
window.alert(t("alerts.importBackendFailed"));
|
||||
return {};
|
||||
}
|
||||
let data: ImportedDataState;
|
||||
if (privateKey) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
const key = await getImportedKey(privateKey, "decrypt");
|
||||
const iv = new Uint8Array(12);
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
key,
|
||||
buffer,
|
||||
);
|
||||
// We need to convert the decrypted array buffer to a string
|
||||
const string = new window.TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted) as any,
|
||||
);
|
||||
data = JSON.parse(string);
|
||||
} else {
|
||||
// Legacy format
|
||||
data = await response.json();
|
||||
}
|
||||
|
||||
trackEvent(EVENT_ACTION, "import");
|
||||
return {
|
||||
elements: data.elements || null,
|
||||
appState: data.appState || null,
|
||||
};
|
||||
} catch (error) {
|
||||
window.alert(t("alerts.importBackendFailed"));
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const loadScene = async (
|
||||
id: string | null,
|
||||
privateKey: string | null,
|
||||
// Supply initialData 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`.
|
||||
initialData: ImportedDataState | undefined | null,
|
||||
) => {
|
||||
let data;
|
||||
if (id != 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),
|
||||
initialData?.appState,
|
||||
);
|
||||
} else {
|
||||
data = restore(initialData || null, null);
|
||||
}
|
||||
|
||||
return {
|
||||
elements: data.elements,
|
||||
appState: data.appState,
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
113
src/excalidraw-app/data/localStorage.ts
Normal file
113
src/excalidraw-app/data/localStorage.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "../../appState";
|
||||
import { clearElementsForLocalStorage } from "../../element";
|
||||
import { STORAGE_KEYS as APP_STORAGE_KEYS } from "../../constants";
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
|
||||
};
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
|
||||
JSON.stringify({ username }),
|
||||
);
|
||||
} catch (error) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const importUsernameFromLocalStorage = (): string | null => {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
if (data) {
|
||||
return JSON.parse(data).username;
|
||||
}
|
||||
} catch (error) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const saveToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
);
|
||||
} catch (error) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const importFromLocalStorage = () => {
|
||||
let savedElements = null;
|
||||
let savedState = null;
|
||||
|
||||
try {
|
||||
savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
} catch (error) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
let elements: ExcalidrawElement[] = [];
|
||||
if (savedElements) {
|
||||
try {
|
||||
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// Do nothing because elements array is already empty
|
||||
}
|
||||
}
|
||||
|
||||
let appState = null;
|
||||
if (savedState) {
|
||||
try {
|
||||
appState = {
|
||||
...getDefaultAppState(),
|
||||
...clearAppStateForLocalStorage(
|
||||
JSON.parse(savedState) as Partial<AppState>,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// Do nothing because appState is already null
|
||||
}
|
||||
}
|
||||
return { elements, appState };
|
||||
};
|
||||
|
||||
export const getTotalStorageSize = () => {
|
||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
const library = localStorage.getItem(APP_STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
|
||||
const appStateSize = appState ? JSON.stringify(appState).length : 0;
|
||||
const collabSize = collab ? JSON.stringify(collab).length : 0;
|
||||
const elementsSize = elements ? JSON.stringify(elements).length : 0;
|
||||
const librarySize = library ? JSON.stringify(library).length : 0;
|
||||
|
||||
return appStateSize + collabSize + elementsSize + librarySize;
|
||||
};
|
Reference in New Issue
Block a user