aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/data/encode.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/data/encode.ts')
-rw-r--r--packages/excalidraw/data/encode.ts413
1 files changed, 413 insertions, 0 deletions
diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts
new file mode 100644
index 0000000..15dfdb2
--- /dev/null
+++ b/packages/excalidraw/data/encode.ts
@@ -0,0 +1,413 @@
+import { deflate, inflate } from "pako";
+import { encryptData, decryptData } from "./encryption";
+
+// -----------------------------------------------------------------------------
+// byte (binary) strings
+// -----------------------------------------------------------------------------
+
+// Buffer-compatible implem.
+//
+// Note that in V8, spreading the uint8array (by chunks) into
+// `String.fromCharCode(...uint8array)` tends to be faster for large
+// strings/buffers, in case perf is needed in the future.
+export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
+ const bytes =
+ typeof data === "string"
+ ? new TextEncoder().encode(data)
+ : data instanceof Uint8Array
+ ? data
+ : new Uint8Array(data);
+ let bstring = "";
+ for (const byte of bytes) {
+ bstring += String.fromCharCode(byte);
+ }
+ return bstring;
+};
+
+const byteStringToArrayBuffer = (byteString: string) => {
+ const buffer = new ArrayBuffer(byteString.length);
+ const bufferView = new Uint8Array(buffer);
+ for (let i = 0, len = byteString.length; i < len; i++) {
+ bufferView[i] = byteString.charCodeAt(i);
+ }
+ return buffer;
+};
+
+const byteStringToString = (byteString: string) => {
+ return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString));
+};
+
+// -----------------------------------------------------------------------------
+// base64
+// -----------------------------------------------------------------------------
+
+/**
+ * @param isByteString set to true if already byte string to prevent bloat
+ * due to reencoding
+ */
+export const stringToBase64 = (str: string, isByteString = false) => {
+ return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
+};
+
+// async to align with stringToBase64
+export const base64ToString = (base64: string, isByteString = false) => {
+ return isByteString
+ ? window.atob(base64)
+ : byteStringToString(window.atob(base64));
+};
+
+export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
+ if (typeof Buffer !== "undefined") {
+ // Node.js environment
+ return Buffer.from(base64, "base64").buffer;
+ }
+ // Browser environment
+ return byteStringToArrayBuffer(atob(base64));
+};
+
+// -----------------------------------------------------------------------------
+// base64url
+// -----------------------------------------------------------------------------
+
+export const base64urlToString = (str: string) => {
+ return window.atob(
+ // normalize base64URL to base64
+ str
+ .replace(/-/g, "+")
+ .replace(/_/g, "/")
+ .padEnd(str.length + ((4 - (str.length % 4)) % 4), "="),
+ );
+};
+
+// -----------------------------------------------------------------------------
+// text encoding
+// -----------------------------------------------------------------------------
+
+type EncodedData = {
+ encoded: string;
+ encoding: "bstring";
+ /** whether text is compressed (zlib) */
+ compressed: boolean;
+ /** version for potential migration purposes */
+ version?: string;
+};
+
+/**
+ * Encodes (and potentially compresses via zlib) text to byte string
+ */
+export const encode = ({
+ text,
+ compress,
+}: {
+ text: string;
+ /** defaults to `true`. If compression fails, falls back to bstring alone. */
+ compress?: boolean;
+}): EncodedData => {
+ let deflated!: string;
+ if (compress !== false) {
+ try {
+ deflated = toByteString(deflate(text));
+ } catch (error: any) {
+ console.error("encode: cannot deflate", error);
+ }
+ }
+ return {
+ version: "1",
+ encoding: "bstring",
+ compressed: !!deflated,
+ encoded: deflated || toByteString(text),
+ };
+};
+
+export const decode = (data: EncodedData): string => {
+ let decoded: string;
+
+ switch (data.encoding) {
+ case "bstring":
+ // if compressed, do not double decode the bstring
+ decoded = data.compressed
+ ? data.encoded
+ : byteStringToString(data.encoded);
+ break;
+ default:
+ throw new Error(`decode: unknown encoding "${data.encoding}"`);
+ }
+
+ if (data.compressed) {
+ return inflate(new Uint8Array(byteStringToArrayBuffer(decoded)), {
+ to: "string",
+ });
+ }
+
+ return decoded;
+};
+
+// -----------------------------------------------------------------------------
+// binary encoding
+// -----------------------------------------------------------------------------
+
+type FileEncodingInfo = {
+ /* version 2 is the version we're shipping the initial image support with.
+ version 1 was a PR version that a lot of people were using anyway.
+ Thus, if there are issues we can check whether they're not using the
+ unoffic version */
+ version: 1 | 2;
+ compression: "pako@1" | null;
+ encryption: "AES-GCM" | null;
+};
+
+// -----------------------------------------------------------------------------
+const CONCAT_BUFFERS_VERSION = 1;
+/** how many bytes we use to encode how many bytes the next chunk has.
+ * Corresponds to DataView setter methods (setUint32, setUint16, etc).
+ *
+ * NOTE ! values must not be changed, which would be backwards incompatible !
+ */
+const VERSION_DATAVIEW_BYTES = 4;
+const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
+// -----------------------------------------------------------------------------
+
+const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
+
+// getter
+function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
+// setter
+function dataView(
+ buffer: Uint8Array,
+ bytes: 1 | 2 | 4,
+ offset: number,
+ value: number,
+): Uint8Array;
+/**
+ * abstraction over DataView that serves as a typed getter/setter in case
+ * you're using constants for the byte size and want to ensure there's no
+ * discrepenancy in the encoding across refactors.
+ *
+ * DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
+ */
+function dataView(
+ buffer: Uint8Array,
+ bytes: 1 | 2 | 4,
+ offset: number,
+ value?: number,
+): Uint8Array | number {
+ if (value != null) {
+ if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
+ throw new Error(
+ `attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
+ );
+ }
+ const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
+ new DataView(buffer.buffer)[method](offset, value);
+ return buffer;
+ }
+ const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
+ return new DataView(buffer.buffer)[method](offset);
+}
+
+// -----------------------------------------------------------------------------
+
+/**
+ * Resulting concatenated buffer has this format:
+ *
+ * [
+ * VERSION chunk (4 bytes)
+ * LENGTH chunk 1 (4 bytes)
+ * DATA chunk 1 (up to 2^32 bits)
+ * LENGTH chunk 2 (4 bytes)
+ * DATA chunk 2 (up to 2^32 bits)
+ * ...
+ * ]
+ *
+ * @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
+ */
+const concatBuffers = (...buffers: Uint8Array[]) => {
+ const bufferView = new Uint8Array(
+ VERSION_DATAVIEW_BYTES +
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
+ buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
+ );
+
+ let cursor = 0;
+
+ // as the first chunk we'll encode the version for backwards compatibility
+ dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
+ cursor += VERSION_DATAVIEW_BYTES;
+
+ for (const buffer of buffers) {
+ dataView(
+ bufferView,
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
+ cursor,
+ buffer.byteLength,
+ );
+ cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
+
+ bufferView.set(buffer, cursor);
+ cursor += buffer.byteLength;
+ }
+
+ return bufferView;
+};
+
+/** can only be used on buffers created via `concatBuffers()` */
+const splitBuffers = (concatenatedBuffer: Uint8Array) => {
+ const buffers = [];
+
+ let cursor = 0;
+
+ // first chunk is the version
+ const version = dataView(
+ concatenatedBuffer,
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
+ cursor,
+ );
+ // If version is outside of the supported versions, throw an error.
+ // This usually means the buffer wasn't encoded using this API, so we'd only
+ // waste compute.
+ if (version > CONCAT_BUFFERS_VERSION) {
+ throw new Error(`invalid version ${version}`);
+ }
+
+ cursor += VERSION_DATAVIEW_BYTES;
+
+ while (true) {
+ const chunkSize = dataView(
+ concatenatedBuffer,
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
+ cursor,
+ );
+ cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
+
+ buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
+ cursor += chunkSize;
+ if (cursor >= concatenatedBuffer.byteLength) {
+ break;
+ }
+ }
+
+ return buffers;
+};
+
+// helpers for (de)compressing data with JSON metadata including encryption
+// -----------------------------------------------------------------------------
+
+/** @private */
+const _encryptAndCompress = async (
+ data: Uint8Array | string,
+ encryptionKey: string,
+) => {
+ const { encryptedBuffer, iv } = await encryptData(
+ encryptionKey,
+ deflate(data),
+ );
+
+ return { iv, buffer: new Uint8Array(encryptedBuffer) };
+};
+
+/**
+ * The returned buffer has following format:
+ * `[]` refers to a buffers wrapper (see `concatBuffers`)
+ *
+ * [
+ * encodingMetadataBuffer,
+ * iv,
+ * [
+ * contentsMetadataBuffer
+ * contentsBuffer
+ * ]
+ * ]
+ */
+export const compressData = async <T extends Record<string, any> = never>(
+ dataBuffer: Uint8Array,
+ options: {
+ encryptionKey: string;
+ } & ([T] extends [never]
+ ? {
+ metadata?: T;
+ }
+ : {
+ metadata: T;
+ }),
+): Promise<Uint8Array> => {
+ const fileInfo: FileEncodingInfo = {
+ version: 2,
+ compression: "pako@1",
+ encryption: "AES-GCM",
+ };
+
+ const encodingMetadataBuffer = new TextEncoder().encode(
+ JSON.stringify(fileInfo),
+ );
+
+ const contentsMetadataBuffer = new TextEncoder().encode(
+ JSON.stringify(options.metadata || null),
+ );
+
+ const { iv, buffer } = await _encryptAndCompress(
+ concatBuffers(contentsMetadataBuffer, dataBuffer),
+ options.encryptionKey,
+ );
+
+ return concatBuffers(encodingMetadataBuffer, iv, buffer);
+};
+
+/** @private */
+const _decryptAndDecompress = async (
+ iv: Uint8Array,
+ decryptedBuffer: Uint8Array,
+ decryptionKey: string,
+ isCompressed: boolean,
+) => {
+ decryptedBuffer = new Uint8Array(
+ await decryptData(iv, decryptedBuffer, decryptionKey),
+ );
+
+ if (isCompressed) {
+ return inflate(decryptedBuffer);
+ }
+
+ return decryptedBuffer;
+};
+
+export const decompressData = async <T extends Record<string, any>>(
+ bufferView: Uint8Array,
+ options: { decryptionKey: string },
+) => {
+ // first chunk is encoding metadata (ignored for now)
+ const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
+
+ const encodingMetadata: FileEncodingInfo = JSON.parse(
+ new TextDecoder().decode(encodingMetadataBuffer),
+ );
+
+ try {
+ const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
+ await _decryptAndDecompress(
+ iv,
+ buffer,
+ options.decryptionKey,
+ !!encodingMetadata.compression,
+ ),
+ );
+
+ const metadata = JSON.parse(
+ new TextDecoder().decode(contentsMetadataBuffer),
+ ) as T;
+
+ return {
+ /** metadata source is always JSON so we can decode it here */
+ metadata,
+ /** data can be anything so the caller must decode it */
+ data: contentsBuffer,
+ };
+ } catch (error: any) {
+ console.error(
+ `Error during decompressing and decrypting the file.`,
+ encodingMetadata,
+ );
+ throw error;
+ }
+};
+
+// -----------------------------------------------------------------------------