123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- import { deflate, inflate } from "pako";
- import { encryptData, decryptData } from "./encryption";
-
- // -----------------------------------------------------------------------------
- // byte (binary) strings
- // -----------------------------------------------------------------------------
-
- // fast, Buffer-compatible implem
- export const toByteString = (
- data: string | Uint8Array | ArrayBuffer,
- ): Promise<string> => {
- return new Promise((resolve, reject) => {
- const blob =
- typeof data === "string"
- ? new Blob([new TextEncoder().encode(data)])
- : new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
- const reader = new FileReader();
- reader.onload = (event) => {
- if (!event.target || typeof event.target.result !== "string") {
- return reject(new Error("couldn't convert to byte string"));
- }
- resolve(event.target.result);
- };
- reader.readAsBinaryString(blob);
- });
- };
-
- 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 = async (str: string, isByteString = false) => {
- return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
- };
-
- // async to align with stringToBase64
- export const base64ToString = async (base64: string, isByteString = false) => {
- return isByteString
- ? window.atob(base64)
- : byteStringToString(window.atob(base64));
- };
-
- // -----------------------------------------------------------------------------
- // 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 = async ({
- text,
- compress,
- }: {
- text: string;
- /** defaults to `true`. If compression fails, falls back to bstring alone. */
- compress?: boolean;
- }): Promise<EncodedData> => {
- let deflated!: string;
- if (compress !== false) {
- try {
- deflated = await toByteString(deflate(text));
- } catch (error) {
- console.error("encode: cannot deflate", error);
- }
- }
- return {
- version: "1",
- encoding: "bstring",
- compressed: !!deflated,
- encoded: deflated || (await toByteString(text)),
- };
- };
-
- export const decode = async (data: EncodedData): Promise<string> => {
- let decoded: string;
-
- switch (data.encoding) {
- case "bstring":
- // if compressed, do not double decode the bstring
- decoded = data.compressed
- ? data.encoded
- : await 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 (ignored for now)
- 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) {
- console.error(
- `Error during decompressing and decrypting the file.`,
- encodingMetadata,
- );
- throw error;
- }
- };
-
- // -----------------------------------------------------------------------------
|