You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
338 lines
9.5 KiB
338 lines
9.5 KiB
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 type { ImportedDataState } from "../../packages/excalidraw/data/types"; |
|
import type { SceneBounds } from "../../packages/excalidraw/element/bounds"; |
|
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers"; |
|
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; |
|
import type { |
|
ExcalidrawElement, |
|
FileId, |
|
OrderedExcalidrawElement, |
|
} from "../../packages/excalidraw/element/types"; |
|
import { t } from "../../packages/excalidraw/i18n"; |
|
import type { |
|
AppState, |
|
BinaryFileData, |
|
BinaryFiles, |
|
SocketId, |
|
UserIdleState, |
|
} from "../../packages/excalidraw/types"; |
|
import type { MakeBrand } from "../../packages/excalidraw/utility-types"; |
|
import { bytesToHexString } from "../../packages/excalidraw/utils"; |
|
import type { WS_SUBTYPES } from "../app_constants"; |
|
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 = OrderedExcalidrawElement & |
|
MakeBrand<"SyncableExcalidrawElement">; |
|
|
|
export const isSyncableElement = ( |
|
element: OrderedExcalidrawElement, |
|
): 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 OrderedExcalidrawElement[], |
|
) => |
|
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, |
|
}; |
|
}; |
|
|
|
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") }; |
|
} |
|
};
|
|
|