feat: add files package
This commit is contained in:
113
packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts
Normal file
113
packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { sleep } from '@standardnotes/utils'
|
||||
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
|
||||
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { DownloadAndDecryptFileOperation } from './DownloadAndDecrypt'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
describe('download and decrypt', () => {
|
||||
let apiService: FilesApiInterface
|
||||
let operation: DownloadAndDecryptFileOperation
|
||||
let file: {
|
||||
encryptedChunkSizes: FileContent['encryptedChunkSizes']
|
||||
encryptionHeader: FileContent['encryptionHeader']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
key: FileContent['key']
|
||||
}
|
||||
let crypto: PureCryptoInterface
|
||||
|
||||
const NumChunks = 5
|
||||
|
||||
const chunkOfSize = (size: number) => {
|
||||
return new TextEncoder().encode('a'.repeat(size))
|
||||
}
|
||||
|
||||
const downloadChunksOfSize = (size: number) => {
|
||||
apiService.downloadFile = jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
_chunkIndex: number,
|
||||
_apiToken: string,
|
||||
_rangeStart: number,
|
||||
onBytesReceived: (bytes: Uint8Array) => void,
|
||||
) => {
|
||||
const receiveFile = async () => {
|
||||
for (let i = 0; i < NumChunks; i++) {
|
||||
onBytesReceived(chunkOfSize(size))
|
||||
|
||||
await sleep(100, false)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
void receiveFile().then(resolve)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {} as jest.Mocked<FilesApiInterface>
|
||||
apiService.createFileValetToken = jest.fn()
|
||||
downloadChunksOfSize(5)
|
||||
|
||||
crypto = {} as jest.Mocked<PureCryptoInterface>
|
||||
|
||||
crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({
|
||||
state: {},
|
||||
} as StreamEncryptor)
|
||||
|
||||
crypto.xchacha20StreamDecryptorPush = jest.fn().mockReturnValue({ message: new Uint8Array([0xaa]), tag: 0 })
|
||||
|
||||
file = {
|
||||
encryptedChunkSizes: [100_000],
|
||||
remoteIdentifier: '123',
|
||||
key: 'secret',
|
||||
encryptionHeader: 'some-header',
|
||||
}
|
||||
})
|
||||
|
||||
it('run should resolve when operation is complete', async () => {
|
||||
let receivedBytes = new Uint8Array()
|
||||
|
||||
operation = new DownloadAndDecryptFileOperation(file, crypto, apiService)
|
||||
|
||||
await operation.run(async (result) => {
|
||||
if (result) {
|
||||
receivedBytes = new Uint8Array([...receivedBytes, ...result.decrypted.decryptedBytes])
|
||||
}
|
||||
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(receivedBytes.length).toEqual(NumChunks)
|
||||
})
|
||||
|
||||
it('should correctly report progress', async () => {
|
||||
file = {
|
||||
encryptedChunkSizes: [100_000, 200_000, 200_000],
|
||||
remoteIdentifier: '123',
|
||||
key: 'secret',
|
||||
encryptionHeader: 'some-header',
|
||||
}
|
||||
|
||||
downloadChunksOfSize(100_000)
|
||||
|
||||
operation = new DownloadAndDecryptFileOperation(file, crypto, apiService)
|
||||
|
||||
const progress: FileDownloadProgress = await new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
void operation.run(async (result) => {
|
||||
operation.abort()
|
||||
resolve(result.progress)
|
||||
})
|
||||
})
|
||||
|
||||
expect(progress.encryptedBytesDownloaded).toEqual(100_000)
|
||||
expect(progress.encryptedBytesRemaining).toEqual(400_000)
|
||||
expect(progress.encryptedFileSize).toEqual(500_000)
|
||||
expect(progress.percentComplete).toEqual(20.0)
|
||||
})
|
||||
})
|
||||
75
packages/files/src/Domain/Operations/DownloadAndDecrypt.ts
Normal file
75
packages/files/src/Domain/Operations/DownloadAndDecrypt.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { AbortFunction, FileDownloader } from '../UseCase/FileDownloader'
|
||||
import { FileDecryptor } from '../UseCase/FileDecryptor'
|
||||
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { DecryptedBytes, EncryptedBytes } from '@standardnotes/filepicker'
|
||||
|
||||
export type DownloadAndDecryptResult = { success: boolean; error?: ClientDisplayableError; aborted?: boolean }
|
||||
|
||||
type OnBytesCallback = (results: {
|
||||
decrypted: DecryptedBytes
|
||||
encrypted: EncryptedBytes
|
||||
progress: FileDownloadProgress
|
||||
}) => Promise<void>
|
||||
|
||||
export class DownloadAndDecryptFileOperation {
|
||||
private downloader: FileDownloader
|
||||
|
||||
constructor(
|
||||
private readonly file: {
|
||||
encryptedChunkSizes: FileContent['encryptedChunkSizes']
|
||||
encryptionHeader: FileContent['encryptionHeader']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
key: FileContent['key']
|
||||
},
|
||||
private readonly crypto: PureCryptoInterface,
|
||||
private readonly api: FilesApiInterface,
|
||||
) {
|
||||
this.downloader = new FileDownloader(this.file, this.api)
|
||||
}
|
||||
|
||||
private createDecryptor(): FileDecryptor {
|
||||
return new FileDecryptor(this.file, this.crypto)
|
||||
}
|
||||
|
||||
public async run(onBytes: OnBytesCallback): Promise<DownloadAndDecryptResult> {
|
||||
const decryptor = this.createDecryptor()
|
||||
|
||||
let decryptError: ClientDisplayableError | undefined
|
||||
|
||||
const onDownloadBytes = async (
|
||||
encryptedBytes: Uint8Array,
|
||||
progress: FileDownloadProgress,
|
||||
abortDownload: AbortFunction,
|
||||
) => {
|
||||
const result = decryptor.decryptBytes(encryptedBytes)
|
||||
|
||||
if (!result || result.decryptedBytes.length === 0) {
|
||||
decryptError = new ClientDisplayableError('Failed to decrypt chunk')
|
||||
|
||||
abortDownload()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const decryptedBytes = result.decryptedBytes
|
||||
|
||||
await onBytes({ decrypted: { decryptedBytes }, encrypted: { encryptedBytes }, progress })
|
||||
}
|
||||
|
||||
const downloadResult = await this.downloader.run(onDownloadBytes)
|
||||
|
||||
return {
|
||||
success: downloadResult instanceof ClientDisplayableError ? false : true,
|
||||
error: downloadResult === 'aborted' ? undefined : downloadResult || decryptError,
|
||||
aborted: downloadResult === 'aborted',
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.downloader.abort()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { EncryptAndUploadFileOperation } from './EncryptAndUpload'
|
||||
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
describe('encrypt and upload', () => {
|
||||
let apiService: FilesApiInterface
|
||||
let operation: EncryptAndUploadFileOperation
|
||||
let file: {
|
||||
decryptedSize: FileContent['decryptedSize']
|
||||
key: FileContent['key']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
}
|
||||
let crypto: PureCryptoInterface
|
||||
|
||||
const chunkOfSize = (size: number) => {
|
||||
return new TextEncoder().encode('a'.repeat(size))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {} as jest.Mocked<FilesApiInterface>
|
||||
apiService.uploadFileBytes = jest.fn().mockReturnValue(true)
|
||||
|
||||
crypto = {} as jest.Mocked<PureCryptoInterface>
|
||||
|
||||
crypto.xchacha20StreamInitEncryptor = jest.fn().mockReturnValue({
|
||||
header: 'some-header',
|
||||
state: {},
|
||||
} as StreamEncryptor)
|
||||
|
||||
crypto.xchacha20StreamEncryptorPush = jest.fn().mockReturnValue(new Uint8Array())
|
||||
|
||||
file = {
|
||||
remoteIdentifier: '123',
|
||||
key: 'secret',
|
||||
decryptedSize: 100,
|
||||
}
|
||||
})
|
||||
|
||||
it('should initialize encryption header', () => {
|
||||
operation = new EncryptAndUploadFileOperation(file, 'api-token', crypto, apiService)
|
||||
|
||||
expect(operation.getResult().encryptionHeader.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return true when a chunk is uploaded', async () => {
|
||||
operation = new EncryptAndUploadFileOperation(file, 'api-token', crypto, apiService)
|
||||
|
||||
const bytes = new Uint8Array()
|
||||
const success = await operation.pushBytes(bytes, 2, false)
|
||||
|
||||
expect(success).toEqual(true)
|
||||
})
|
||||
|
||||
it('should correctly report progress', async () => {
|
||||
operation = new EncryptAndUploadFileOperation(file, 'api-token', crypto, apiService)
|
||||
|
||||
const bytes = chunkOfSize(60)
|
||||
await operation.pushBytes(bytes, 2, false)
|
||||
|
||||
const progress = operation.getProgress()
|
||||
|
||||
expect(progress.decryptedFileSize).toEqual(100)
|
||||
expect(progress.decryptedBytesUploaded).toEqual(60)
|
||||
expect(progress.decryptedBytesRemaining).toEqual(40)
|
||||
expect(progress.percentComplete).toEqual(60.0)
|
||||
})
|
||||
})
|
||||
86
packages/files/src/Domain/Operations/EncryptAndUpload.ts
Normal file
86
packages/files/src/Domain/Operations/EncryptAndUpload.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { FileUploadProgress } from '../Types/FileUploadProgress'
|
||||
import { FileUploadResult } from '../Types/FileUploadResult'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { FileUploader } from '../UseCase/FileUploader'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { FileEncryptor } from '../UseCase/FileEncryptor'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
export class EncryptAndUploadFileOperation {
|
||||
public readonly encryptedChunkSizes: number[] = []
|
||||
|
||||
private readonly encryptor: FileEncryptor
|
||||
private readonly uploader: FileUploader
|
||||
private readonly encryptionHeader: string
|
||||
|
||||
private totalBytesPushedInDecryptedTerms = 0
|
||||
private totalBytesUploadedInDecryptedTerms = 0
|
||||
|
||||
constructor(
|
||||
private file: {
|
||||
decryptedSize: FileContent['decryptedSize']
|
||||
key: FileContent['key']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
},
|
||||
private apiToken: string,
|
||||
private crypto: PureCryptoInterface,
|
||||
private api: FilesApiInterface,
|
||||
) {
|
||||
this.encryptor = new FileEncryptor(file, this.crypto)
|
||||
this.uploader = new FileUploader(this.api)
|
||||
|
||||
this.encryptionHeader = this.encryptor.initializeHeader()
|
||||
}
|
||||
|
||||
public getApiToken(): string {
|
||||
return this.apiToken
|
||||
}
|
||||
|
||||
public getProgress(): FileUploadProgress {
|
||||
const reportedDecryptedSize = this.file.decryptedSize
|
||||
|
||||
return {
|
||||
decryptedFileSize: reportedDecryptedSize,
|
||||
decryptedBytesUploaded: this.totalBytesUploadedInDecryptedTerms,
|
||||
decryptedBytesRemaining: reportedDecryptedSize - this.totalBytesUploadedInDecryptedTerms,
|
||||
percentComplete: (this.totalBytesUploadedInDecryptedTerms / reportedDecryptedSize) * 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
public getResult(): FileUploadResult {
|
||||
return {
|
||||
encryptionHeader: this.encryptionHeader,
|
||||
finalDecryptedSize: this.totalBytesPushedInDecryptedTerms,
|
||||
key: this.file.key,
|
||||
remoteIdentifier: this.file.remoteIdentifier,
|
||||
}
|
||||
}
|
||||
|
||||
public async pushBytes(decryptedBytes: Uint8Array, chunkId: number, isFinalChunk: boolean): Promise<boolean> {
|
||||
this.totalBytesPushedInDecryptedTerms += decryptedBytes.byteLength
|
||||
|
||||
const encryptedBytes = this.encryptBytes(decryptedBytes, isFinalChunk)
|
||||
|
||||
this.encryptedChunkSizes.push(encryptedBytes.length)
|
||||
|
||||
const uploadSuccess = await this.uploadBytes(encryptedBytes, chunkId)
|
||||
|
||||
if (uploadSuccess) {
|
||||
this.totalBytesUploadedInDecryptedTerms += decryptedBytes.byteLength
|
||||
}
|
||||
|
||||
return uploadSuccess
|
||||
}
|
||||
|
||||
private encryptBytes(decryptedBytes: Uint8Array, isFinalChunk: boolean): Uint8Array {
|
||||
const encryptedBytes = this.encryptor.pushBytes(decryptedBytes, isFinalChunk)
|
||||
|
||||
return encryptedBytes
|
||||
}
|
||||
|
||||
private async uploadBytes(encryptedBytes: Uint8Array, chunkId: number): Promise<boolean> {
|
||||
const success = await this.uploader.uploadBytes(encryptedBytes, chunkId, this.apiToken)
|
||||
|
||||
return success
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user