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.
319 lines
8.7 KiB
319 lines
8.7 KiB
import { reconcileElements } from "@excalidraw/excalidraw"; |
|
import { MIME_TYPES } from "@excalidraw/common"; |
|
import { decompressData } from "@excalidraw/excalidraw/data/encode"; |
|
import { |
|
encryptData, |
|
decryptData, |
|
} from "@excalidraw/excalidraw/data/encryption"; |
|
import { restoreElements } from "@excalidraw/excalidraw/data/restore"; |
|
import { getSceneVersion } from "@excalidraw/element"; |
|
import { initializeApp } from "firebase/app"; |
|
import { |
|
getFirestore, |
|
doc, |
|
getDoc, |
|
runTransaction, |
|
Bytes, |
|
} from "firebase/firestore"; |
|
import { getStorage, ref, uploadBytes } from "firebase/storage"; |
|
|
|
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; |
|
import type { |
|
ExcalidrawElement, |
|
FileId, |
|
OrderedExcalidrawElement, |
|
} from "@excalidraw/element/types"; |
|
import type { |
|
AppState, |
|
BinaryFileData, |
|
BinaryFileMetadata, |
|
DataURL, |
|
} from "@excalidraw/excalidraw/types"; |
|
|
|
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; |
|
|
|
import { getSyncableElements } from "."; |
|
|
|
import type { SyncableExcalidrawElement } from "."; |
|
import type Portal from "../collab/Portal"; |
|
import type { Socket } from "socket.io-client"; |
|
|
|
// private |
|
// ----------------------------------------------------------------------------- |
|
|
|
let FIREBASE_CONFIG: Record<string, any>; |
|
try { |
|
FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG); |
|
} catch (error: any) { |
|
console.warn( |
|
`Error JSON parsing firebase config. Supplied value: ${ |
|
import.meta.env.VITE_APP_FIREBASE_CONFIG |
|
}`, |
|
); |
|
FIREBASE_CONFIG = {}; |
|
} |
|
|
|
let firebaseApp: ReturnType<typeof initializeApp> | null = null; |
|
let firestore: ReturnType<typeof getFirestore> | null = null; |
|
let firebaseStorage: ReturnType<typeof getStorage> | null = null; |
|
|
|
const _initializeFirebase = () => { |
|
if (!firebaseApp) { |
|
firebaseApp = initializeApp(FIREBASE_CONFIG); |
|
} |
|
return firebaseApp; |
|
}; |
|
|
|
const _getFirestore = () => { |
|
if (!firestore) { |
|
firestore = getFirestore(_initializeFirebase()); |
|
} |
|
return firestore; |
|
}; |
|
|
|
const _getStorage = () => { |
|
if (!firebaseStorage) { |
|
firebaseStorage = getStorage(_initializeFirebase()); |
|
} |
|
return firebaseStorage; |
|
}; |
|
|
|
// ----------------------------------------------------------------------------- |
|
|
|
export const loadFirebaseStorage = async () => { |
|
return _getStorage(); |
|
}; |
|
|
|
type FirebaseStoredScene = { |
|
sceneVersion: number; |
|
iv: Bytes; |
|
ciphertext: Bytes; |
|
}; |
|
|
|
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 }; |
|
}; |
|
|
|
const decryptElements = async ( |
|
data: FirebaseStoredScene, |
|
roomKey: string, |
|
): Promise<readonly ExcalidrawElement[]> => { |
|
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>; |
|
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>; |
|
|
|
const decrypted = await decryptData(iv, ciphertext, roomKey); |
|
const decodedData = new TextDecoder("utf-8").decode( |
|
new Uint8Array(decrypted), |
|
); |
|
return JSON.parse(decodedData); |
|
}; |
|
|
|
class FirebaseSceneVersionCache { |
|
private static cache = new WeakMap<Socket, number>(); |
|
static get = (socket: Socket) => { |
|
return FirebaseSceneVersionCache.cache.get(socket); |
|
}; |
|
static set = ( |
|
socket: Socket, |
|
elements: readonly SyncableExcalidrawElement[], |
|
) => { |
|
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements)); |
|
}; |
|
} |
|
|
|
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 saveFilesToFirebase = async ({ |
|
prefix, |
|
files, |
|
}: { |
|
prefix: string; |
|
files: { id: FileId; buffer: Uint8Array }[]; |
|
}) => { |
|
const storage = await loadFirebaseStorage(); |
|
|
|
const erroredFiles: FileId[] = []; |
|
const savedFiles: FileId[] = []; |
|
|
|
await Promise.all( |
|
files.map(async ({ id, buffer }) => { |
|
try { |
|
const storageRef = ref(storage, `${prefix}/${id}`); |
|
await uploadBytes(storageRef, buffer, { |
|
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`, |
|
}); |
|
savedFiles.push(id); |
|
} catch (error: any) { |
|
erroredFiles.push(id); |
|
} |
|
}), |
|
); |
|
|
|
return { savedFiles, erroredFiles }; |
|
}; |
|
|
|
const createFirebaseSceneDocument = async ( |
|
elements: readonly SyncableExcalidrawElement[], |
|
roomKey: string, |
|
) => { |
|
const sceneVersion = getSceneVersion(elements); |
|
const { ciphertext, iv } = await encryptElements(roomKey, elements); |
|
return { |
|
sceneVersion, |
|
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)), |
|
iv: Bytes.fromUint8Array(iv), |
|
} as FirebaseStoredScene; |
|
}; |
|
|
|
export const saveToFirebase = async ( |
|
portal: Portal, |
|
elements: readonly SyncableExcalidrawElement[], |
|
appState: AppState, |
|
) => { |
|
const { roomId, roomKey, socket } = portal; |
|
if ( |
|
// bail if no room exists as there's nothing we can do at this point |
|
!roomId || |
|
!roomKey || |
|
!socket || |
|
isSavedToFirebase(portal, elements) |
|
) { |
|
return null; |
|
} |
|
|
|
const firestore = _getFirestore(); |
|
const docRef = doc(firestore, "scenes", roomId); |
|
|
|
const storedScene = await runTransaction(firestore, async (transaction) => { |
|
const snapshot = await transaction.get(docRef); |
|
|
|
if (!snapshot.exists()) { |
|
const storedScene = await createFirebaseSceneDocument(elements, roomKey); |
|
|
|
transaction.set(docRef, storedScene); |
|
|
|
return storedScene; |
|
} |
|
|
|
const prevStoredScene = snapshot.data() as FirebaseStoredScene; |
|
const prevStoredElements = getSyncableElements( |
|
restoreElements(await decryptElements(prevStoredScene, roomKey), null), |
|
); |
|
const reconciledElements = getSyncableElements( |
|
reconcileElements( |
|
elements, |
|
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[], |
|
appState, |
|
), |
|
); |
|
|
|
const storedScene = await createFirebaseSceneDocument( |
|
reconciledElements, |
|
roomKey, |
|
); |
|
|
|
transaction.update(docRef, storedScene); |
|
|
|
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime |
|
return storedScene; |
|
}); |
|
|
|
const storedElements = getSyncableElements( |
|
restoreElements(await decryptElements(storedScene, roomKey), null), |
|
); |
|
|
|
FirebaseSceneVersionCache.set(socket, storedElements); |
|
|
|
return storedElements; |
|
}; |
|
|
|
export const loadFromFirebase = async ( |
|
roomId: string, |
|
roomKey: string, |
|
socket: Socket | null, |
|
): Promise<readonly SyncableExcalidrawElement[] | null> => { |
|
const firestore = _getFirestore(); |
|
const docRef = doc(firestore, "scenes", roomId); |
|
const docSnap = await getDoc(docRef); |
|
if (!docSnap.exists()) { |
|
return null; |
|
} |
|
const storedScene = docSnap.data() as FirebaseStoredScene; |
|
const elements = getSyncableElements( |
|
restoreElements(await decryptElements(storedScene, roomKey), null, { |
|
deleteInvisibleElements: true, |
|
}), |
|
); |
|
|
|
if (socket) { |
|
FirebaseSceneVersionCache.set(socket, elements); |
|
} |
|
|
|
return elements; |
|
}; |
|
|
|
export const loadFilesFromFirebase = 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 { |
|
const url = `https://firebasestorage.googleapis.com/v0/b/${ |
|
FIREBASE_CONFIG.storageBucket |
|
}/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`; |
|
const response = await fetch(`${url}?alt=media`); |
|
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(), |
|
lastRetrieved: metadata?.created || Date.now(), |
|
}); |
|
} else { |
|
erroredFiles.set(id, true); |
|
} |
|
} catch (error: any) { |
|
erroredFiles.set(id, true); |
|
console.error(error); |
|
} |
|
}), |
|
); |
|
|
|
return { loadedFiles, erroredFiles }; |
|
};
|
|
|