diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index c2ccaa1b696..d2b33f33a37 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -96,7 +96,9 @@ "dompurify": "^3.3.3", "filesize": "^11.0.14", "fuse.js": "^7.1.0", + "hash-wasm": "^4.12.0", "highlight.js": "^11.12.0", + "js-sha1": "^0.7.0", "lodash-es": "^4.17.23", "lowlight": "^3.3.0", "luxon": "^3.7.2", diff --git a/packages/web-pkg/src/composables/upload/useUpload.ts b/packages/web-pkg/src/composables/upload/useUpload.ts index b467888ba21..46d053c5e2a 100644 --- a/packages/web-pkg/src/composables/upload/useUpload.ts +++ b/packages/web-pkg/src/composables/upload/useUpload.ts @@ -43,6 +43,15 @@ export function useUpload(options: UploadOptions) { return headers } + // the checksum meta field is ' ', a PUT carries it as 'OC-Checksum: :' + function getChecksumHeader(file: OcUppyFile): Record { + const [algorithm, checksum] = file?.meta?.checksum?.split(' ') ?? [] + if (!algorithm || !checksum) { + return {} + } + return { 'OC-Checksum': `${algorithm.toUpperCase()}:${checksum}` } + } + const tusOptions = computed(() => { const options: OcTusOptions = { onBeforeRequest: (req, file) => @@ -82,6 +91,7 @@ export function useUpload(options: UploadOptions) { endpoint: '', headers: (file) => ({ 'x-oc-mtime': ((file?.data as File)?.lastModified / 1000).toFixed(0), + ...getChecksumHeader(file), ...getHeaders() }) } diff --git a/packages/web-pkg/src/services/uppy/checksum/index.ts b/packages/web-pkg/src/services/uppy/checksum/index.ts new file mode 100644 index 00000000000..2f4ad0ef7f8 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/index.ts @@ -0,0 +1,2 @@ +export * from './plugin' +export * from './sha1' diff --git a/packages/web-pkg/src/services/uppy/checksum/plugin.ts b/packages/web-pkg/src/services/uppy/checksum/plugin.ts new file mode 100644 index 00000000000..707146ac2e9 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/plugin.ts @@ -0,0 +1,75 @@ +import Uppy, { BasePlugin } from '@uppy/core' +import type { OcUppyBody, OcUppyFile, OcUppyMeta } from '../uppyService' +import { computeSha1 } from './sha1' + +/** + * Uppy pre-processor that computes the SHA1 of every file and stores it in the `checksum` meta + * field as `sha1 `, the format the server expects in the tus `Upload-Metadata` header. + * The server compares it with the checksum of the received bytes and rejects the upload on a + * mismatch, so a damaged or spliced upload fails instead of being stored. + * + * Pre-processors run when the upload starts, after other code (e.g. vault encryption) has + * replaced `file.data`, so the hash covers the bytes that are actually sent. + */ +export class UploadChecksumPlugin extends BasePlugin { + constructor(uppy: Uppy, opts?: object) { + super(uppy, opts) + this.id = 'UploadChecksum' + this.type = 'modifier' + } + + prepare = async (fileIDs: string[]) => { + for (const file of this.uppy.getFilesByIds(fileIDs)) { + if (!this.needsChecksum(file)) { + continue + } + + // file.size may be null while file.data.size is always set for local files + const size = file.data.size + this.uppy.emit('preprocess-progress', file, { mode: 'determinate', message: '', value: 0 }) + try { + const checksum = await computeSha1(file.data as Blob, { + onProgress: (bytesHashed) => { + this.uppy.emit('preprocess-progress', this.uppy.getFile(file.id), { + mode: 'determinate', + message: '', + value: size ? bytesHashed / size : 1 + }) + } + }) + this.uppy.setFileMeta(file.id, { + ...this.uppy.getFile(file.id).meta, + checksum: `sha1 ${checksum}` + }) + } catch (error) { + // never upload without a checksum, fail this file instead + this.uppy.log( + `[UploadChecksum] failed to compute checksum of ${file.name}: ${error}`, + 'error' + ) + this.uppy.emit( + 'upload-error', + this.uppy.getFile(file.id), + error instanceof Error ? error : new Error(String(error)) + ) + } + this.uppy.emit('preprocess-complete', this.uppy.getFile(file.id)) + } + } + + private needsChecksum(file: OcUppyFile) { + // folders have no content, remote files (e.g. from companion) have no local data to hash, + // and a retried upload keeps the checksum it already has + return ( + !file.meta.isFolder && !file.isRemote && !file.error && !file.meta.checksum && !!file.data + ) + } + + install() { + this.uppy.addPreProcessor(this.prepare) + } + + uninstall() { + this.uppy.removePreProcessor(this.prepare) + } +} diff --git a/packages/web-pkg/src/services/uppy/checksum/sha1.ts b/packages/web-pkg/src/services/uppy/checksum/sha1.ts new file mode 100644 index 00000000000..1035743c385 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/sha1.ts @@ -0,0 +1,51 @@ +import ChecksumWorker from './worker?worker' + +export const CHECKSUM_CHUNK_SIZE = 8 * 1024 * 1024 + +export type ChecksumWorkerRequest = { + blob: Blob + chunkSize: number +} + +export type ChecksumWorkerResponse = + | { type: 'progress'; bytesHashed: number } + | { type: 'done'; checksum: string } + | { type: 'error'; message: string } + +/** + * Computes the hex encoded SHA1 of a blob in a web worker, so hashing large files does not + * block the UI. + */ +export function computeSha1( + blob: Blob, + { + chunkSize = CHECKSUM_CHUNK_SIZE, + onProgress + }: { chunkSize?: number; onProgress?: (bytesHashed: number) => void } = {} +): Promise { + return new Promise((resolve, reject) => { + const worker = new (ChecksumWorker as unknown as new () => Worker)() + + worker.onmessage = (e: MessageEvent) => { + const message = e.data + switch (message.type) { + case 'progress': + onProgress?.(message.bytesHashed) + return + case 'done': + worker.terminate() + resolve(message.checksum) + return + case 'error': + worker.terminate() + reject(new Error(message.message)) + } + } + worker.onerror = (e) => { + worker.terminate() + reject(new Error(e.message || 'checksum worker failed')) + } + + worker.postMessage({ blob, chunkSize } satisfies ChecksumWorkerRequest) + }) +} diff --git a/packages/web-pkg/src/services/uppy/checksum/worker.ts b/packages/web-pkg/src/services/uppy/checksum/worker.ts new file mode 100644 index 00000000000..63dd9b347fc --- /dev/null +++ b/packages/web-pkg/src/services/uppy/checksum/worker.ts @@ -0,0 +1,46 @@ +import { createSHA1 } from 'hash-wasm' +import { sha1 } from 'js-sha1' +import type { ChecksumWorkerRequest, ChecksumWorkerResponse } from './sha1' + +type Hasher = { + update(data: Uint8Array): void + hex(): string +} + +function post(message: ChecksumWorkerResponse) { + postMessage(message) +} + +// hash-wasm is several times faster, but compiling WebAssembly needs 'wasm-unsafe-eval' in the +// Content-Security-Policy's script-src. Without it, compiling throws before any data is hashed, +// and the pure JavaScript implementation is used instead. Both produce the same SHA1. +async function createHasher(): Promise { + try { + const hasher = await createSHA1() + hasher.init() + return { update: (data) => hasher.update(data), hex: () => hasher.digest('hex') } + } catch (error) { + console.info( + `[UploadChecksum] WebAssembly is not available (${error instanceof Error ? error.message : error}), using the slower JavaScript SHA1` + ) + const hasher = sha1.create() + return { update: (data) => hasher.update(data), hex: () => hasher.hex() } + } +} + +// Hashes the blob slice by slice, so that large files never have to be loaded into memory +// at once. crypto.subtle.digest cannot hash incrementally. +self.onmessage = async (e: MessageEvent) => { + const { blob, chunkSize } = e.data + try { + const hasher = await createHasher() + for (let offset = 0; offset < blob.size; offset += chunkSize) { + const end = Math.min(offset + chunkSize, blob.size) + hasher.update(new Uint8Array(await blob.slice(offset, end).arrayBuffer())) + post({ type: 'progress', bytesHashed: end }) + } + post({ type: 'done', checksum: hasher.hex() }) + } catch (error) { + post({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } +} diff --git a/packages/web-pkg/src/services/uppy/uppyService.ts b/packages/web-pkg/src/services/uppy/uppyService.ts index 8c79a212f91..ea2e8f6e4c0 100644 --- a/packages/web-pkg/src/services/uppy/uppyService.ts +++ b/packages/web-pkg/src/services/uppy/uppyService.ts @@ -7,6 +7,7 @@ import { eventBus } from '../eventBus' import DropTarget from './DropTarget/plugin' import { Resource, urlJoin } from '@opencloud-eu/web-client' import { generateFileID, Body, MinimalRequiredUppyFile } from '@uppy/utils' +import { UploadChecksumPlugin } from './checksum' type UppyServiceTopics = | 'uploadStarted' @@ -21,6 +22,8 @@ type UppyServiceTopics = | 'drag-over' | 'drag-out' | 'drop' + | 'preprocess-progress' + | 'preprocess-complete' export type uppyHeaders = { [name: string]: string | number @@ -45,6 +48,8 @@ type FileWithPath = File & { export type OcUppyMeta = { name?: string mtime?: number + // whole-file checksum as ' ', set by the UploadChecksum plugin + checksum?: string // current space & folder spaceId: string spaceName: string @@ -70,8 +75,10 @@ export type OcUppyMeta = { export type OcUppyBody = Body // Meta fields safe to put in the tus `Upload-Metadata` header. This should -// only include fields that are part of the TUS spec. -export const TUS_ALLOWED_META_FIELDS: (keyof OcUppyMeta)[] = ['name', 'mtime'] +// only include fields that are part of the TUS spec, or that the server needs. +// `checksum` is a hash of the transmitted bytes (the ciphertext for vault uploads), +// so it does not reveal any path or cleartext information. +export const TUS_ALLOWED_META_FIELDS: (keyof OcUppyMeta)[] = ['name', 'mtime', 'checksum'] export type OcUppyFile = UppyFile type OcUppyPlugin = typeof BasePlugin @@ -127,6 +134,9 @@ export class UppyService { } }) + // compute a whole-file checksum before every upload, see UploadChecksumPlugin + this.uppy.use(UploadChecksumPlugin) + this.setUpEvents() } @@ -269,6 +279,12 @@ export class UppyService { this.uppy.on('upload-progress', (file, progress) => { this.publish('upload-progress', { file, progress }) }) + this.uppy.on('preprocess-progress', (file, progress) => { + this.publish('preprocess-progress', { file, progress }) + }) + this.uppy.on('preprocess-complete', (file) => { + this.publish('preprocess-complete', file) + }) this.uppy.on('cancel-all', () => { this.publish('uploadCancelled') this.clearInputs() diff --git a/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts b/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts index 3d33df2d91d..11e8902fabc 100644 --- a/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts +++ b/packages/web-pkg/tests/unit/composables/upload/useUpload.spec.ts @@ -1,7 +1,44 @@ +import { mock } from 'vitest-mock-extended' +import { XHRUploadOptions } from '@uppy/xhr-upload' +import { defaultComponentMocks, getComposableWrapper } from '@opencloud-eu/web-test-helpers' import { useUpload } from '../../../../src/composables/upload' +import { CapabilityStore } from '../../../../src/composables' +import { OcUppyBody, OcUppyFile, OcUppyMeta, UppyService } from '../../../../src/services/uppy' describe('useUpload', () => { it('should be valid', () => { expect(useUpload).toBeDefined() }) + + describe('plain PUT uploads', () => { + it('send the checksum as OC-Checksum header', () => { + const headers = getXhrHeaders({ + meta: { checksum: 'sha1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' } + } as OcUppyFile) + expect(headers['OC-Checksum']).toBe('SHA1:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d') + }) + + it('send no OC-Checksum header without a checksum', () => { + const headers = getXhrHeaders({ meta: {} } as OcUppyFile) + expect(headers).not.toHaveProperty('OC-Checksum') + }) + }) }) + +function getXhrHeaders(file: OcUppyFile) { + const uppyService = mock() + const mocks = defaultComponentMocks() + // a max chunk size of 0 means no tus support, so the plain PUT (XHR) uploader is used + const capabilities = { + files: { tus_support: { max_chunk_size: 0, extension: '' } } + } as unknown as Partial + + getComposableWrapper(() => useUpload({ uppyService }), { + mocks, + provide: mocks, + pluginOptions: { piniaOptions: { capabilityState: { capabilities } } } + }) + + const options = uppyService.useXhr.mock.calls[0][0] as XHRUploadOptions + return (options.headers as (file: OcUppyFile) => Record)(file) +} diff --git a/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts b/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts new file mode 100644 index 00000000000..87acb9cd958 --- /dev/null +++ b/packages/web-pkg/tests/unit/services/uppy/checksum.spec.ts @@ -0,0 +1,157 @@ +import { createHash, randomBytes } from 'node:crypto' +import Uppy, { BasePlugin } from '@uppy/core' +import { computeSha1, UploadChecksumPlugin } from '../../../../src/services/uppy/checksum' +import { OcUppyBody, OcUppyMeta } from '../../../../src/services/uppy' + +const sha1Hex = (data: Uint8Array) => createHash('sha1').update(data).digest('hex') + +// an uploader that records the files it was asked to upload instead of sending them +class RecordingUploader extends BasePlugin { + uploaded: { name: string; meta: OcUppyMeta }[] = [] + + constructor(uppy: Uppy) { + super(uppy, {}) + this.id = 'RecordingUploader' + this.type = 'uploader' + } + + upload = (fileIDs: string[]) => { + for (const file of this.uppy.getFilesByIds(fileIDs)) { + if (file.error) { + continue + } + this.uploaded.push({ name: file.name, meta: file.meta }) + this.uppy.emit('upload-success', file, { status: 200, body: {}, uploadURL: '' }) + } + return Promise.resolve() + } + + install() { + this.uppy.addUploader(this.upload) + } + + uninstall() { + this.uppy.removeUploader(this.upload) + } +} + +const createUppy = () => { + const uppy = new Uppy() + uppy.use(UploadChecksumPlugin) + uppy.use(RecordingUploader) + return { uppy, uploader: uppy.getPlugin('RecordingUploader') as RecordingUploader } +} + +describe('upload checksums', () => { + // happy-dom's Blob loses its content when structured-cloned into the emulated worker, + // browsers keep it. Pass messages by reference in these tests. + beforeAll(() => { + vi.stubEnv('VITEST_WEB_WORKER_CLONE', 'none') + }) + afterAll(() => { + vi.unstubAllEnvs() + }) + + describe('computeSha1', () => { + it('computes the SHA1 of a blob', async () => { + expect(await computeSha1(new Blob(['hello']))).toBe( + 'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' + ) + }) + + it('computes the SHA1 of an empty blob', async () => { + expect(await computeSha1(new Blob([]))).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709') + }) + + it('hashes the blob slice by slice', async () => { + const data = new Uint8Array(randomBytes(3 * 1024 + 17)) + const progress: number[] = [] + + const checksum = await computeSha1(new Blob([data]), { + chunkSize: 1024, + onProgress: (bytesHashed) => progress.push(bytesHashed) + }) + + expect(checksum).toBe(sha1Hex(data)) + // the emulated worker may deliver a message more than once without cloning + expect([...new Set(progress)]).toEqual([1024, 2048, 3072, 3 * 1024 + 17]) + }) + }) + + describe('when WebAssembly is blocked, e.g. by the Content-Security-Policy', () => { + beforeEach(() => { + const blocked = () => { + throw new Error('Compiling or instantiating WebAssembly module violates the CSP') + } + vi.stubGlobal('WebAssembly', { + compile: () => Promise.reject(new Error('blocked by CSP')), + instantiate: () => Promise.reject(new Error('blocked by CSP')), + Module: blocked, + Instance: blocked, + Memory: blocked + }) + vi.spyOn(console, 'info').mockImplementation(() => undefined) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('computes the same SHA1 with the JavaScript implementation', async () => { + const data = new Uint8Array(randomBytes(3 * 1024 + 17)) + + const checksum = await computeSha1(new Blob([data]), { chunkSize: 1024 }) + + expect(checksum).toBe(sha1Hex(data)) + expect(console.info).toHaveBeenCalledWith(expect.stringContaining('JavaScript SHA1')) + }) + }) + + describe('UploadChecksumPlugin', () => { + it('sets the checksum meta field before the upload starts', async () => { + const { uppy, uploader } = createUppy() + uppy.addFile({ name: 'hello.txt', data: new Blob(['hello']) }) + + const result = await uppy.upload() + + expect(result.failed).toHaveLength(0) + expect(uploader.uploaded).toHaveLength(1) + expect(uploader.uploaded[0].meta.checksum).toBe( + 'sha1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' + ) + }) + + it('hashes the data that is actually uploaded', async () => { + const { uppy, uploader } = createUppy() + const id = uppy.addFile({ name: 'secret.txt', data: new Blob(['cleartext']) }) + // e.g. a vault upload replaces the content with ciphertext before the upload starts + uppy.setFileState(id, { data: new Blob(['ciphertext']) }) + + await uppy.upload() + + expect(uploader.uploaded[0].meta.checksum).toBe( + 'sha1 ' + sha1Hex(new TextEncoder().encode('ciphertext')) + ) + }) + + it('does not hash folders', async () => { + const { uppy, uploader } = createUppy() + uppy.addFile({ name: 'folder', data: new Blob([]), meta: { isFolder: true } as OcUppyMeta }) + + await uppy.upload() + + expect(uploader.uploaded[0].meta.checksum).toBeUndefined() + }) + + it('fails the file instead of uploading it without a checksum', async () => { + const { uppy, uploader } = createUppy() + // data that cannot be read, so hashing fails + uppy.addFile({ name: 'broken.txt', data: { size: 6 } as unknown as Blob }) + uppy.addFile({ name: 'fine.txt', data: new Blob(['fine']) }) + + const result = await uppy.upload() + + expect(uploader.uploaded.map(({ name }) => name)).toEqual(['fine.txt']) + expect(result.failed.map(({ name }) => name)).toEqual(['broken.txt']) + }) + }) +}) diff --git a/packages/web-pkg/tests/unit/services/uppy/uppyService.spec.ts b/packages/web-pkg/tests/unit/services/uppy/uppyService.spec.ts new file mode 100644 index 00000000000..2584595e630 --- /dev/null +++ b/packages/web-pkg/tests/unit/services/uppy/uppyService.spec.ts @@ -0,0 +1,51 @@ +import { Language } from 'vue3-gettext' +import { getAllowedMetaFields } from '@uppy/core/utils' +import type { TusOptions } from '@uppy/tus' +import { OcUppyBody, OcUppyMeta, UppyService } from '../../../../src/services/uppy' + +describe('UppyService', () => { + beforeAll(() => { + // happy-dom's Blob loses its content when cloned into the emulated checksum worker + vi.stubEnv('VITEST_WEB_WORKER_CLONE', 'none') + }) + afterAll(() => { + vi.unstubAllEnvs() + }) + + it('puts the checksum but no path information into the tus Upload-Metadata', async () => { + const uppyService = new UppyService({ + language: { $gettext: (msg: string) => msg } as unknown as Language + }) + uppyService.useTus({ chunkSize: Infinity, uploadDataDuringCreation: false, headers: {} }) + // the transport itself is not under test (tus-js-client can't send Blobs in node) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const id = uppyService.uppy.addFile({ + name: 'hello.txt', + data: new Blob(['hello']), + meta: { + mtime: 1700000000, + relativeFolder: 'secret', + relativePath: '/secret/hello.txt', + currentFolder: '/private/folder', + routeDriveAliasAndItem: 'personal/admin/private/folder' + } as OcUppyMeta + }) + uppyService.uppy.setFileState(id, { tus: { endpoint: 'https://example.org/dav/spaces/1' } }) + await uppyService.uploadFiles() + + // the Upload-Metadata entries, built the way @uppy/tus builds them + const { allowedMetaFields } = uppyService.getPlugin('Tus').opts as TusOptions< + OcUppyMeta, + OcUppyBody + > + const file = uppyService.uppy.getFile(id) + const metadataKeys = getAllowedMetaFields( + allowedMetaFields, + file.meta as unknown as Record + ) + + expect(file.meta.checksum).toBe('sha1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d') + expect([...metadataKeys].sort()).toEqual(['checksum', 'mtime', 'name']) + }) +}) diff --git a/packages/web-runtime/src/components/UploadInfo.vue b/packages/web-runtime/src/components/UploadInfo.vue index 7ff379d142c..8713bd5b651 100644 --- a/packages/web-runtime/src/components/UploadInfo.vue +++ b/packages/web-runtime/src/components/UploadInfo.vue @@ -536,6 +536,10 @@ function retryUploads() { } function getUploadItemMessage(item: UploadResult) { + if (item.status === 'preparing') { + return $gettext('Calculating checksum...') + } + const error = unref(errors)[item.meta.uploadId] if (!error) { @@ -623,6 +627,19 @@ uppyService.subscribe('addedForUpload', (files: OcUppyFile[]) => { } } }) +// the upload checksum is computed before a file is uploaded, which can take a while for big files +uppyService.subscribe('preprocess-progress', ({ file }: { file: OcUppyFile }) => { + const item = unref(uploads)[file?.meta.uploadId] + if (item && !item.status) { + item.status = 'preparing' + } +}) +uppyService.subscribe('preprocess-complete', (file: OcUppyFile) => { + const item = unref(uploads)[file?.meta.uploadId] + if (item?.status === 'preparing') { + item.status = undefined + } +}) uppyService.subscribe('uploadCompleted', () => { runningUploads.value -= 1 diff --git a/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts b/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts index e98794ac379..8be7b0691c2 100644 --- a/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts +++ b/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts @@ -242,6 +242,23 @@ describe('UploadInfo component', () => { expect(infoMessages.at(0).text()).toBe('Unknown error') expect(infoMessages.at(1).text()).toBe('Unknown error') }) + it('should show that a file is being prepared while its checksum is calculated', async () => { + const { wrapper, mocks } = getShallowWrapper() + const file = { name: 'file', path: '/', type: 'file', meta: { uploadId: '1' } } + ;(wrapper.vm as any).showInfo = true + ;(wrapper.vm as any).infoExpanded = true + ;(wrapper.vm as any).uploads = { '1': file as unknown as OcUppyFile } + const subscriber = (topic: string) => + mocks.$uppyService.subscribe.mock.calls.find(([t]) => t === topic)[1] + + subscriber('preprocess-progress')({ file, progress: { value: 0.5 } }) + await nextTick() + expect(wrapper.find(selectors.message).text()).toBe('Calculating checksum...') + + subscriber('preprocess-complete')(file) + await nextTick() + expect(wrapper.find(selectors.message).exists()).toBeFalsy() + }) it('folder is clickable', async () => { const { wrapper } = getShallowWrapper() ;(wrapper.vm as any).showInfo = true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 061d3352f89..64d535eb547 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1095,9 +1095,15 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.5.0 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 highlight.js: specifier: ^11.12.0 version: 11.12.0 + js-sha1: + specifier: ^0.7.0 + version: 0.7.0 lodash-es: specifier: ^4.17.23 version: 4.18.1 @@ -3464,6 +3470,9 @@ packages: resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} engines: {node: '>= 0.8'} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} @@ -3657,6 +3666,9 @@ packages: js-cookie@3.0.8: resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-sha1@0.7.0: + resolution: {integrity: sha512-oQZ1Mo7440BfLSv9TX87VNEyU52pXPVG19F9PL3gTgNt0tVxlZ8F4O6yze3CLuLx28TxotxvlyepCNaaV0ZjMw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -7504,6 +7516,8 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + hash-wasm@4.12.0: {} + hash.js@1.1.7: dependencies: inherits: 2.0.4 @@ -7683,6 +7697,8 @@ snapshots: js-cookie@3.0.8: {} + js-sha1@0.7.0: {} + js-tokens@10.0.0: {} json-parse-even-better-errors@3.0.2: {}