feat: add files package

This commit is contained in:
Karol Sójko
2022-07-05 20:35:19 +02:00
parent 730853e67a
commit 1cd8a47fa4
33 changed files with 1771 additions and 23 deletions

View 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)
})
})

View 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()
}
}

View File

@@ -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)
})
})

View 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
}
}