basic Socket.io implementation of collaborative editing (#879)

* Enable collaborative syncing for elements

* Don't fall back to local storage if using a room, as that is confusing

* Use remote socket server

* Send updates to new users when they join

* ~

* add mouse tracking

* enable collaboration, rooms, and mouse tracking

* fix syncing bugs and add a button to start syncing mid session

* enable collaboration, rooms, and mouse tracking

* fix syncing bugs and add a button to start syncing mid session

* Add Live button and app state to support tracking collaborator counts

* Enable collaborative syncing for elements

* add mouse tracking

* enable collaboration, rooms, and mouse tracking

* fix syncing bugs and add a button to start syncing mid session

* fix syncing bugs and add a button to start syncing mid session

* Add Live button and app state to support tracking collaborator counts

* prettier

* Fix bug with remote pointers not changing on scroll

* Enable collaborative syncing for elements

* add mouse tracking

* enable collaboration, rooms, and mouse tracking

* fix syncing bugs and add a button to start syncing mid session

* enable collaboration, rooms, and mouse tracking

* fix syncing bugs and add a button to start syncing mid session

* Add Live button and app state to support tracking collaborator counts

* enable collaboration, rooms, and mouse tracking

* fix syncing bugs and add a button to start syncing mid session

* fix syncing bugs and add a button to start syncing mid session

* Fix bug with remote pointers not changing on scroll

* remove UI for collaboration

* remove link

* clean up lingering unused UI

* set random IV passed per encrypted message, reduce room id length, refactored socket broadcasting API, rename room_id to room, removed throttling of pointer movement

* fix package.json conflict
This commit is contained in:
Edwin Lin 2020-03-09 08:48:25 -07:00 committed by GitHub
parent 463854e42a
commit 0e5c29b3f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 575 additions and 28 deletions

View file

@ -23,11 +23,145 @@ const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
const BACKEND_V2_POST = "https://json.excalidraw.com/api/v2/post/";
const BACKEND_V2_GET = "https://json.excalidraw.com/api/v2/";
export const SOCKET_SERVER = "https://excalidraw-socket.herokuapp.com";
export type EncryptedData = {
data: ArrayBuffer;
iv: Uint8Array;
};
export type SocketUpdateData =
| {
type: "SCENE_UPDATE";
payload: {
elements: readonly ExcalidrawElement[];
appState: AppState | null;
};
}
| {
type: "MOUSE_LOCATION";
payload: {
socketID: string;
pointerCoords: { x: number; y: number };
};
}
| {
type: "INVALID_RESPONSE";
};
// TODO: Defined globally, since file handles aren't yet serializable.
// Once `FileSystemFileHandle` can be serialized, make this
// part of `AppState`.
(window as any).handle = null;
function byteToHex(byte: number): string {
return `0${byte.toString(16)}`.slice(-2);
}
async function generateRandomID() {
const arr = new Uint8Array(10);
window.crypto.getRandomValues(arr);
return Array.from(arr, byteToHex).join("");
}
async function generateEncryptionKey() {
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;
}
function createIV() {
const arr = new Uint8Array(12);
return window.crypto.getRandomValues(arr);
}
export function 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 async function generateCollaborationLink() {
const id = await generateRandomID();
const key = await generateEncryptionKey();
return `${window.location.href}#room=${id},${key}`;
}
async function getImportedKey(key: string, usage: string): Promise<CryptoKey> {
return await 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],
);
}
export async function encryptAESGEM(
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 async function decryptAESGEM(
data: ArrayBuffer,
key: string,
iv: Uint8Array,
): Promise<SocketUpdateData> {
try {
const importedKey = await getImportedKey(key, "decrypt");
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: 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 async function exportToBackend(
elements: readonly ExcalidrawElement[],
appState: AppState,
@ -101,22 +235,7 @@ export async function importFromBackend(
let data;
if (privateKey) {
const buffer = await response.arrayBuffer();
const key = await window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: privateKey,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: 128,
},
false, // extractable
["decrypt"],
);
const key = await getImportedKey(privateKey, "decrypt");
const iv = new Uint8Array(12);
const decrypted = await window.crypto.subtle.decrypt(
{