feat: add files package
This commit is contained in:
173
packages/files/src/Domain/Backups/BackupService.ts
Normal file
173
packages/files/src/Domain/Backups/BackupService.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { ContentType, Uuid } from '@standardnotes/common'
|
||||
import { EncryptionProvider } from '@standardnotes/encryption'
|
||||
import { PayloadEmitSource, FileItem, CreateEncryptedBackupFileContextPayload } from '@standardnotes/models'
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import {
|
||||
ItemManagerInterface,
|
||||
FileBackupsDevice,
|
||||
FileBackupsMapping,
|
||||
AbstractService,
|
||||
InternalEventBusInterface,
|
||||
StatusServiceInterface,
|
||||
FileBackupMetadataFile,
|
||||
FilesApiInterface,
|
||||
} from '@standardnotes/services'
|
||||
|
||||
export class FilesBackupService extends AbstractService {
|
||||
private itemsObserverDisposer: () => void
|
||||
private pendingFiles = new Set<Uuid>()
|
||||
|
||||
constructor(
|
||||
private items: ItemManagerInterface,
|
||||
private api: FilesApiInterface,
|
||||
private encryptor: EncryptionProvider,
|
||||
private device: FileBackupsDevice,
|
||||
private status: StatusServiceInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
|
||||
this.itemsObserverDisposer = items.addObserver<FileItem>(ContentType.File, ({ changed, inserted, source }) => {
|
||||
const applicableSources = [
|
||||
PayloadEmitSource.LocalDatabaseLoaded,
|
||||
PayloadEmitSource.RemoteSaved,
|
||||
PayloadEmitSource.RemoteRetrieved,
|
||||
]
|
||||
|
||||
if (applicableSources.includes(source)) {
|
||||
void this.handleChangedFiles([...changed, ...inserted])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.itemsObserverDisposer()
|
||||
}
|
||||
|
||||
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||
return this.device.isFilesBackupsEnabled()
|
||||
}
|
||||
|
||||
public async enableFilesBackups(): Promise<void> {
|
||||
await this.device.enableFilesBackups()
|
||||
|
||||
if (!(await this.isFilesBackupsEnabled())) {
|
||||
return
|
||||
}
|
||||
|
||||
this.backupAllFiles()
|
||||
}
|
||||
|
||||
private backupAllFiles(): void {
|
||||
const files = this.items.getItems<FileItem>(ContentType.File)
|
||||
|
||||
void this.handleChangedFiles(files)
|
||||
}
|
||||
|
||||
public disableFilesBackups(): Promise<void> {
|
||||
return this.device.disableFilesBackups()
|
||||
}
|
||||
|
||||
public changeFilesBackupsLocation(): Promise<string | undefined> {
|
||||
return this.device.changeFilesBackupsLocation()
|
||||
}
|
||||
|
||||
public getFilesBackupsLocation(): Promise<string> {
|
||||
return this.device.getFilesBackupsLocation()
|
||||
}
|
||||
|
||||
public openFilesBackupsLocation(): Promise<void> {
|
||||
return this.device.openFilesBackupsLocation()
|
||||
}
|
||||
|
||||
private async getBackupsMapping(): Promise<FileBackupsMapping['files']> {
|
||||
return (await this.device.getFilesBackupsMappingFile()).files
|
||||
}
|
||||
|
||||
private async handleChangedFiles(files: FileItem[]): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await this.isFilesBackupsEnabled())) {
|
||||
return
|
||||
}
|
||||
|
||||
const mapping = await this.getBackupsMapping()
|
||||
|
||||
for (const file of files) {
|
||||
if (this.pendingFiles.has(file.uuid)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = mapping[file.uuid]
|
||||
|
||||
if (record == undefined) {
|
||||
this.pendingFiles.add(file.uuid)
|
||||
|
||||
await this.performBackupOperation(file)
|
||||
|
||||
this.pendingFiles.delete(file.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async performBackupOperation(file: FileItem): Promise<'success' | 'failed' | 'aborted'> {
|
||||
const messageId = this.status.addMessage(`Backing up file ${file.name}...`)
|
||||
|
||||
const encryptedFile = await this.encryptor.encryptSplitSingle({
|
||||
usesItemsKeyWithKeyLookup: {
|
||||
items: [file.payload],
|
||||
},
|
||||
})
|
||||
|
||||
const itemsKey = this.items.getDisplayableItemsKeys().find((k) => k.uuid === encryptedFile.items_key_id)
|
||||
|
||||
if (!itemsKey) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const encryptedItemsKey = await this.encryptor.encryptSplitSingle({
|
||||
usesRootKeyWithKeyLookup: {
|
||||
items: [itemsKey.payload],
|
||||
},
|
||||
})
|
||||
|
||||
const token = await this.api.createFileValetToken(file.remoteIdentifier, 'read')
|
||||
|
||||
if (token instanceof ClientDisplayableError) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const metaFile: FileBackupMetadataFile = {
|
||||
info: {
|
||||
warning: 'Do not edit this file.',
|
||||
information: 'The file and key data below is encrypted with your account password.',
|
||||
instructions:
|
||||
'Drag and drop this metadata file into the File Backups preferences pane in the Standard Notes desktop or web application interface.',
|
||||
},
|
||||
file: CreateEncryptedBackupFileContextPayload(encryptedFile.ejected()),
|
||||
itemsKey: CreateEncryptedBackupFileContextPayload(encryptedItemsKey.ejected()),
|
||||
version: '1.0.0',
|
||||
}
|
||||
|
||||
const metaFileAsString = JSON.stringify(metaFile, null, 2)
|
||||
|
||||
const result = await this.device.saveFilesBackupsFile(file.uuid, metaFileAsString, {
|
||||
chunkSizes: file.encryptedChunkSizes,
|
||||
url: this.api.getFilesDownloadUrl(),
|
||||
valetToken: token,
|
||||
})
|
||||
|
||||
this.status.removeMessage(messageId)
|
||||
|
||||
if (result === 'failed') {
|
||||
const failMessageId = this.status.addMessage(`Failed to back up ${file.name}...`)
|
||||
setTimeout(() => {
|
||||
this.status.removeMessage(failMessageId)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
121
packages/files/src/Domain/Service/FileService.spec.ts
Normal file
121
packages/files/src/Domain/Service/FileService.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
InternalEventBusInterface,
|
||||
SyncServiceInterface,
|
||||
ItemManagerInterface,
|
||||
AlertService,
|
||||
ApiServiceInterface,
|
||||
ChallengeServiceInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { FileService } from './FileService'
|
||||
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
|
||||
import { FileItem } from '@standardnotes/models'
|
||||
import { EncryptionProvider } from '@standardnotes/encryption'
|
||||
|
||||
describe('fileService', () => {
|
||||
let apiService: ApiServiceInterface
|
||||
let itemManager: ItemManagerInterface
|
||||
let syncService: SyncServiceInterface
|
||||
let alertService: AlertService
|
||||
let crypto: PureCryptoInterface
|
||||
let challengor: ChallengeServiceInterface
|
||||
let fileService: FileService
|
||||
let encryptor: EncryptionProvider
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {} as jest.Mocked<ApiServiceInterface>
|
||||
apiService.addEventObserver = jest.fn()
|
||||
apiService.createFileValetToken = jest.fn()
|
||||
apiService.downloadFile = jest.fn()
|
||||
apiService.deleteFile = jest.fn().mockReturnValue({})
|
||||
|
||||
itemManager = {} as jest.Mocked<ItemManagerInterface>
|
||||
itemManager.createItem = jest.fn()
|
||||
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
|
||||
itemManager.setItemToBeDeleted = jest.fn()
|
||||
itemManager.addObserver = jest.fn()
|
||||
itemManager.changeItem = jest.fn()
|
||||
|
||||
challengor = {} as jest.Mocked<ChallengeServiceInterface>
|
||||
|
||||
syncService = {} as jest.Mocked<SyncServiceInterface>
|
||||
syncService.sync = jest.fn()
|
||||
|
||||
encryptor = {} as jest.Mocked<EncryptionProvider>
|
||||
|
||||
alertService = {} as jest.Mocked<AlertService>
|
||||
alertService.confirm = jest.fn().mockReturnValue(true)
|
||||
alertService.alert = jest.fn()
|
||||
|
||||
crypto = {} as jest.Mocked<PureCryptoInterface>
|
||||
crypto.base64Decode = jest.fn()
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
internalEventBus.publish = jest.fn()
|
||||
|
||||
fileService = new FileService(
|
||||
apiService,
|
||||
itemManager,
|
||||
syncService,
|
||||
encryptor,
|
||||
challengor,
|
||||
alertService,
|
||||
crypto,
|
||||
internalEventBus,
|
||||
)
|
||||
|
||||
crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({
|
||||
state: {},
|
||||
} as StreamEncryptor)
|
||||
|
||||
crypto.xchacha20StreamDecryptorPush = jest.fn().mockReturnValue({ message: new Uint8Array([0xaa]), tag: 0 })
|
||||
|
||||
crypto.xchacha20StreamInitEncryptor = jest.fn().mockReturnValue({
|
||||
header: 'some-header',
|
||||
state: {},
|
||||
} as StreamEncryptor)
|
||||
|
||||
crypto.xchacha20StreamEncryptorPush = jest.fn().mockReturnValue(new Uint8Array())
|
||||
})
|
||||
|
||||
it.only('should cache file after download', async () => {
|
||||
const file = {
|
||||
uuid: '1',
|
||||
decryptedSize: 100_000,
|
||||
encryptedSize: 101_000,
|
||||
encryptedChunkSizes: [101_000],
|
||||
} as jest.Mocked<FileItem>
|
||||
|
||||
let downloadMock = apiService.downloadFile as jest.Mock
|
||||
|
||||
await fileService.downloadFile(file, async () => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
expect(downloadMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
downloadMock = apiService.downloadFile = jest.fn()
|
||||
|
||||
await fileService.downloadFile(file, async () => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
expect(downloadMock).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(fileService['encryptedCache'].get(file.uuid)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('deleting file should remove it from cache', async () => {
|
||||
const file = {
|
||||
uuid: '1',
|
||||
decryptedSize: 100_000,
|
||||
} as jest.Mocked<FileItem>
|
||||
|
||||
await fileService.downloadFile(file, async () => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await fileService.deleteFile(file)
|
||||
|
||||
expect(fileService['encryptedCache'].get(file.uuid)).toBeFalsy()
|
||||
})
|
||||
})
|
||||
314
packages/files/src/Domain/Service/FileService.ts
Normal file
314
packages/files/src/Domain/Service/FileService.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { DecryptedBytes, EncryptedBytes, FileMemoryCache, OrderedByteChunker } from '@standardnotes/filepicker'
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { DownloadAndDecryptFileOperation } from '../Operations/DownloadAndDecrypt'
|
||||
import { EncryptAndUploadFileOperation } from '../Operations/EncryptAndUpload'
|
||||
import {
|
||||
FileItem,
|
||||
FileProtocolV1Constants,
|
||||
FileMetadata,
|
||||
FileContentSpecialized,
|
||||
FillItemContentSpecialized,
|
||||
FileContent,
|
||||
EncryptedPayload,
|
||||
isEncryptedPayload,
|
||||
} from '@standardnotes/models'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { UuidGenerator } from '@standardnotes/utils'
|
||||
import {
|
||||
AbstractService,
|
||||
InternalEventBusInterface,
|
||||
ItemManagerInterface,
|
||||
SyncServiceInterface,
|
||||
AlertService,
|
||||
FileSystemApi,
|
||||
FilesApiInterface,
|
||||
FileBackupMetadataFile,
|
||||
FileHandleRead,
|
||||
FileSystemNoSelection,
|
||||
ChallengeServiceInterface,
|
||||
FileBackupsConstantsV1,
|
||||
} from '@standardnotes/services'
|
||||
import { FilesClientInterface } from './FilesClientInterface'
|
||||
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||
import { readAndDecryptBackupFile } from './ReadAndDecryptBackupFile'
|
||||
import { DecryptItemsKeyWithUserFallback, EncryptionProvider, SNItemsKey } from '@standardnotes/encryption'
|
||||
import { FileDecryptor } from '../UseCase/FileDecryptor'
|
||||
|
||||
const OneHundredMb = 100 * 1_000_000
|
||||
|
||||
export class FileService extends AbstractService implements FilesClientInterface {
|
||||
private encryptedCache: FileMemoryCache = new FileMemoryCache(OneHundredMb)
|
||||
|
||||
constructor(
|
||||
private api: FilesApiInterface,
|
||||
private itemManager: ItemManagerInterface,
|
||||
private syncService: SyncServiceInterface,
|
||||
private encryptor: EncryptionProvider,
|
||||
private challengor: ChallengeServiceInterface,
|
||||
private alertService: AlertService,
|
||||
private crypto: PureCryptoInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
}
|
||||
|
||||
override deinit(): void {
|
||||
super.deinit()
|
||||
|
||||
this.encryptedCache.clear()
|
||||
;(this.encryptedCache as unknown) = undefined
|
||||
;(this.api as unknown) = undefined
|
||||
;(this.itemManager as unknown) = undefined
|
||||
;(this.encryptor as unknown) = undefined
|
||||
;(this.syncService as unknown) = undefined
|
||||
;(this.alertService as unknown) = undefined
|
||||
;(this.challengor as unknown) = undefined
|
||||
;(this.crypto as unknown) = undefined
|
||||
}
|
||||
|
||||
public minimumChunkSize(): number {
|
||||
return 5_000_000
|
||||
}
|
||||
|
||||
public async beginNewFileUpload(
|
||||
sizeInBytes: number,
|
||||
): Promise<EncryptAndUploadFileOperation | ClientDisplayableError> {
|
||||
const remoteIdentifier = UuidGenerator.GenerateUuid()
|
||||
const tokenResult = await this.api.createFileValetToken(remoteIdentifier, 'write', sizeInBytes)
|
||||
|
||||
if (tokenResult instanceof ClientDisplayableError) {
|
||||
return tokenResult
|
||||
}
|
||||
|
||||
const key = this.crypto.generateRandomKey(FileProtocolV1Constants.KeySize)
|
||||
|
||||
const fileParams = {
|
||||
key,
|
||||
remoteIdentifier,
|
||||
decryptedSize: sizeInBytes,
|
||||
}
|
||||
|
||||
const uploadOperation = new EncryptAndUploadFileOperation(fileParams, tokenResult, this.crypto, this.api)
|
||||
|
||||
const uploadSessionStarted = await this.api.startUploadSession(tokenResult)
|
||||
|
||||
if (!uploadSessionStarted.uploadId) {
|
||||
return new ClientDisplayableError('Could not start upload session')
|
||||
}
|
||||
|
||||
return uploadOperation
|
||||
}
|
||||
|
||||
public async pushBytesForUpload(
|
||||
operation: EncryptAndUploadFileOperation,
|
||||
bytes: Uint8Array,
|
||||
chunkId: number,
|
||||
isFinalChunk: boolean,
|
||||
): Promise<ClientDisplayableError | undefined> {
|
||||
const success = await operation.pushBytes(bytes, chunkId, isFinalChunk)
|
||||
|
||||
if (!success) {
|
||||
return new ClientDisplayableError('Failed to push file bytes to server')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
public async finishUpload(
|
||||
operation: EncryptAndUploadFileOperation,
|
||||
fileMetadata: FileMetadata,
|
||||
): Promise<FileItem | ClientDisplayableError> {
|
||||
const uploadSessionClosed = await this.api.closeUploadSession(operation.getApiToken())
|
||||
|
||||
if (!uploadSessionClosed) {
|
||||
return new ClientDisplayableError('Could not close upload session')
|
||||
}
|
||||
|
||||
const result = operation.getResult()
|
||||
|
||||
const fileContent: FileContentSpecialized = {
|
||||
decryptedSize: result.finalDecryptedSize,
|
||||
encryptedChunkSizes: operation.encryptedChunkSizes,
|
||||
encryptionHeader: result.encryptionHeader,
|
||||
key: result.key,
|
||||
mimeType: fileMetadata.mimeType,
|
||||
name: fileMetadata.name,
|
||||
remoteIdentifier: result.remoteIdentifier,
|
||||
}
|
||||
|
||||
const file = await this.itemManager.createItem<FileItem>(
|
||||
ContentType.File,
|
||||
FillItemContentSpecialized(fileContent),
|
||||
true,
|
||||
)
|
||||
|
||||
await this.syncService.sync()
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
private async decryptCachedEntry(file: FileItem, entry: EncryptedBytes): Promise<DecryptedBytes> {
|
||||
const decryptOperation = new FileDecryptor(file, this.crypto)
|
||||
|
||||
let decryptedAggregate = new Uint8Array()
|
||||
|
||||
const orderedChunker = new OrderedByteChunker(file.encryptedChunkSizes, async (encryptedBytes) => {
|
||||
const decryptedBytes = decryptOperation.decryptBytes(encryptedBytes)
|
||||
|
||||
if (decryptedBytes) {
|
||||
decryptedAggregate = new Uint8Array([...decryptedAggregate, ...decryptedBytes.decryptedBytes])
|
||||
}
|
||||
})
|
||||
|
||||
await orderedChunker.addBytes(entry.encryptedBytes)
|
||||
|
||||
return { decryptedBytes: decryptedAggregate }
|
||||
}
|
||||
|
||||
public async downloadFile(
|
||||
file: FileItem,
|
||||
onDecryptedBytes: (decryptedBytes: Uint8Array, progress?: FileDownloadProgress) => Promise<void>,
|
||||
): Promise<ClientDisplayableError | undefined> {
|
||||
const cachedBytes = this.encryptedCache.get(file.uuid)
|
||||
|
||||
if (cachedBytes) {
|
||||
const decryptedBytes = await this.decryptCachedEntry(file, cachedBytes)
|
||||
|
||||
await onDecryptedBytes(decryptedBytes.decryptedBytes, undefined)
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const addToCache = file.encryptedSize < this.encryptedCache.maxSize
|
||||
|
||||
let cacheEntryAggregate = new Uint8Array()
|
||||
|
||||
const operation = new DownloadAndDecryptFileOperation(file, this.crypto, this.api)
|
||||
|
||||
const result = await operation.run(async ({ decrypted, encrypted, progress }): Promise<void> => {
|
||||
if (addToCache) {
|
||||
cacheEntryAggregate = new Uint8Array([...cacheEntryAggregate, ...encrypted.encryptedBytes])
|
||||
}
|
||||
return onDecryptedBytes(decrypted.decryptedBytes, progress)
|
||||
})
|
||||
|
||||
if (addToCache) {
|
||||
this.encryptedCache.add(file.uuid, { encryptedBytes: cacheEntryAggregate })
|
||||
}
|
||||
|
||||
return result.error
|
||||
}
|
||||
|
||||
public async deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined> {
|
||||
this.encryptedCache.remove(file.uuid)
|
||||
|
||||
const tokenResult = await this.api.createFileValetToken(file.remoteIdentifier, 'delete')
|
||||
|
||||
if (tokenResult instanceof ClientDisplayableError) {
|
||||
return tokenResult
|
||||
}
|
||||
|
||||
const result = await this.api.deleteFile(tokenResult)
|
||||
|
||||
if (result.error) {
|
||||
return ClientDisplayableError.FromError(result.error)
|
||||
}
|
||||
|
||||
await this.itemManager.setItemToBeDeleted(file)
|
||||
await this.syncService.sync()
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
public isFileNameFileBackupRelated(name: string): 'metadata' | 'binary' | false {
|
||||
if (name === FileBackupsConstantsV1.MetadataFileName) {
|
||||
return 'metadata'
|
||||
} else if (name === FileBackupsConstantsV1.BinaryFileName) {
|
||||
return 'binary'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public async decryptBackupMetadataFile(metdataFile: FileBackupMetadataFile): Promise<FileItem | undefined> {
|
||||
const encryptedItemsKey = new EncryptedPayload({
|
||||
...metdataFile.itemsKey,
|
||||
waitingForKey: false,
|
||||
errorDecrypting: false,
|
||||
})
|
||||
|
||||
const decryptedItemsKeyResult = await DecryptItemsKeyWithUserFallback(
|
||||
encryptedItemsKey,
|
||||
this.encryptor,
|
||||
this.challengor,
|
||||
)
|
||||
|
||||
if (decryptedItemsKeyResult === 'failed' || decryptedItemsKeyResult === 'aborted') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const encryptedFile = new EncryptedPayload({ ...metdataFile.file, waitingForKey: false, errorDecrypting: false })
|
||||
|
||||
const itemsKey = new SNItemsKey(decryptedItemsKeyResult)
|
||||
|
||||
const decryptedFile = await this.encryptor.decryptSplitSingle<FileContent>({
|
||||
usesItemsKey: {
|
||||
items: [encryptedFile],
|
||||
key: itemsKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (isEncryptedPayload(decryptedFile)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new FileItem(decryptedFile)
|
||||
}
|
||||
|
||||
public async selectFile(fileSystem: FileSystemApi): Promise<FileHandleRead | FileSystemNoSelection> {
|
||||
const result = await fileSystem.selectFile()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public async readBackupFileAndSaveDecrypted(
|
||||
fileHandle: FileHandleRead,
|
||||
file: FileItem,
|
||||
fileSystem: FileSystemApi,
|
||||
): Promise<'success' | 'aborted' | 'failed'> {
|
||||
const destinationDirectoryHandle = await fileSystem.selectDirectory()
|
||||
|
||||
if (destinationDirectoryHandle === 'aborted' || destinationDirectoryHandle === 'failed') {
|
||||
return destinationDirectoryHandle
|
||||
}
|
||||
|
||||
const destinationFileHandle = await fileSystem.createFile(destinationDirectoryHandle, file.name)
|
||||
|
||||
if (destinationFileHandle === 'aborted' || destinationFileHandle === 'failed') {
|
||||
return destinationFileHandle
|
||||
}
|
||||
|
||||
const result = await readAndDecryptBackupFile(fileHandle, file, fileSystem, this.crypto, async (decryptedBytes) => {
|
||||
await fileSystem.saveBytes(destinationFileHandle, decryptedBytes)
|
||||
})
|
||||
|
||||
await fileSystem.closeFileWriteStream(destinationFileHandle)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public async readBackupFileBytesDecrypted(
|
||||
fileHandle: FileHandleRead,
|
||||
file: FileItem,
|
||||
fileSystem: FileSystemApi,
|
||||
): Promise<Uint8Array> {
|
||||
let bytes = new Uint8Array()
|
||||
|
||||
await readAndDecryptBackupFile(fileHandle, file, fileSystem, this.crypto, async (decryptedBytes) => {
|
||||
bytes = new Uint8Array([...bytes, ...decryptedBytes])
|
||||
})
|
||||
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
48
packages/files/src/Domain/Service/FilesClientInterface.ts
Normal file
48
packages/files/src/Domain/Service/FilesClientInterface.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { EncryptAndUploadFileOperation } from '../Operations/EncryptAndUpload'
|
||||
import { FileItem, FileMetadata } from '@standardnotes/models'
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||
import { FileSystemApi, FileBackupMetadataFile, FileHandleRead, FileSystemNoSelection } from '@standardnotes/services'
|
||||
|
||||
export interface FilesClientInterface {
|
||||
beginNewFileUpload(sizeInBytes: number): Promise<EncryptAndUploadFileOperation | ClientDisplayableError>
|
||||
|
||||
pushBytesForUpload(
|
||||
operation: EncryptAndUploadFileOperation,
|
||||
bytes: Uint8Array,
|
||||
chunkId: number,
|
||||
isFinalChunk: boolean,
|
||||
): Promise<ClientDisplayableError | undefined>
|
||||
|
||||
finishUpload(
|
||||
operation: EncryptAndUploadFileOperation,
|
||||
fileMetadata: FileMetadata,
|
||||
): Promise<FileItem | ClientDisplayableError>
|
||||
|
||||
downloadFile(
|
||||
file: FileItem,
|
||||
onDecryptedBytes: (bytes: Uint8Array, progress: FileDownloadProgress | undefined) => Promise<void>,
|
||||
): Promise<ClientDisplayableError | undefined>
|
||||
|
||||
deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined>
|
||||
|
||||
minimumChunkSize(): number
|
||||
|
||||
isFileNameFileBackupRelated(name: string): 'metadata' | 'binary' | false
|
||||
|
||||
decryptBackupMetadataFile(metdataFile: FileBackupMetadataFile): Promise<FileItem | undefined>
|
||||
|
||||
selectFile(fileSystem: FileSystemApi): Promise<FileHandleRead | FileSystemNoSelection>
|
||||
|
||||
readBackupFileAndSaveDecrypted(
|
||||
fileHandle: FileHandleRead,
|
||||
file: FileItem,
|
||||
fileSystem: FileSystemApi,
|
||||
): Promise<'success' | 'aborted' | 'failed'>
|
||||
|
||||
readBackupFileBytesDecrypted(
|
||||
fileHandle: FileHandleRead,
|
||||
file: FileItem,
|
||||
fileSystem: FileSystemApi,
|
||||
): Promise<Uint8Array>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { FileSystemApi, FileHandleRead } from '@standardnotes/services'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { OrderedByteChunker } from '@standardnotes/filepicker'
|
||||
import { FileDecryptor } from '../UseCase/FileDecryptor'
|
||||
|
||||
export async function readAndDecryptBackupFile(
|
||||
fileHandle: FileHandleRead,
|
||||
file: {
|
||||
encryptionHeader: FileContent['encryptionHeader']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
encryptedChunkSizes: FileContent['encryptedChunkSizes']
|
||||
key: FileContent['key']
|
||||
},
|
||||
fileSystem: FileSystemApi,
|
||||
crypto: PureCryptoInterface,
|
||||
onDecryptedBytes: (decryptedBytes: Uint8Array) => Promise<void>,
|
||||
): Promise<'aborted' | 'failed' | 'success'> {
|
||||
const decryptor = new FileDecryptor(file, crypto)
|
||||
|
||||
const byteChunker = new OrderedByteChunker(file.encryptedChunkSizes, async (chunk: Uint8Array) => {
|
||||
const decryptResult = decryptor.decryptBytes(chunk)
|
||||
|
||||
if (!decryptResult) {
|
||||
return
|
||||
}
|
||||
|
||||
await onDecryptedBytes(decryptResult.decryptedBytes)
|
||||
})
|
||||
|
||||
const readResult = await fileSystem.readFile(fileHandle, async (encryptedBytes: Uint8Array) => {
|
||||
await byteChunker.addBytes(encryptedBytes)
|
||||
})
|
||||
|
||||
return readResult
|
||||
}
|
||||
6
packages/files/src/Domain/Types/FileDownloadProgress.ts
Normal file
6
packages/files/src/Domain/Types/FileDownloadProgress.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type FileDownloadProgress = {
|
||||
encryptedFileSize: number
|
||||
encryptedBytesDownloaded: number
|
||||
encryptedBytesRemaining: number
|
||||
percentComplete: number
|
||||
}
|
||||
6
packages/files/src/Domain/Types/FileUploadProgress.ts
Normal file
6
packages/files/src/Domain/Types/FileUploadProgress.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type FileUploadProgress = {
|
||||
decryptedFileSize: number
|
||||
decryptedBytesUploaded: number
|
||||
decryptedBytesRemaining: number
|
||||
percentComplete: number
|
||||
}
|
||||
6
packages/files/src/Domain/Types/FileUploadResult.ts
Normal file
6
packages/files/src/Domain/Types/FileUploadResult.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type FileUploadResult = {
|
||||
encryptionHeader: string
|
||||
finalDecryptedSize: number
|
||||
key: string
|
||||
remoteIdentifier: string
|
||||
}
|
||||
51
packages/files/src/Domain/UseCase/FileDecryptor.spec.ts
Normal file
51
packages/files/src/Domain/UseCase/FileDecryptor.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FileDecryptor } from './FileDecryptor'
|
||||
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { assert } from '@standardnotes/utils'
|
||||
|
||||
describe('file decryptor', () => {
|
||||
let decryptor: FileDecryptor
|
||||
let file: {
|
||||
encryptionHeader: FileContent['encryptionHeader']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
key: FileContent['key']
|
||||
}
|
||||
let crypto: PureCryptoInterface
|
||||
|
||||
beforeEach(() => {
|
||||
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 = {
|
||||
remoteIdentifier: '123',
|
||||
encryptionHeader: 'some-header',
|
||||
key: 'secret',
|
||||
}
|
||||
|
||||
decryptor = new FileDecryptor(file, crypto)
|
||||
})
|
||||
|
||||
it('initialize', () => {
|
||||
expect(crypto.xchacha20StreamInitDecryptor).toHaveBeenCalledWith(file.encryptionHeader, file.key)
|
||||
})
|
||||
|
||||
it('decryptBytes should return decrypted bytes', () => {
|
||||
const encryptedBytes = new Uint8Array([0xaa])
|
||||
const result = decryptor.decryptBytes(encryptedBytes)
|
||||
|
||||
assert(result)
|
||||
|
||||
expect(crypto.xchacha20StreamDecryptorPush).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
encryptedBytes,
|
||||
file.remoteIdentifier,
|
||||
)
|
||||
|
||||
expect(result.decryptedBytes.length).toEqual(1)
|
||||
})
|
||||
})
|
||||
29
packages/files/src/Domain/UseCase/FileDecryptor.ts
Normal file
29
packages/files/src/Domain/UseCase/FileDecryptor.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { PureCryptoInterface, StreamDecryptor, SodiumConstant } from '@standardnotes/sncrypto-common'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
export class FileDecryptor {
|
||||
private decryptor: StreamDecryptor
|
||||
|
||||
constructor(
|
||||
private file: {
|
||||
encryptionHeader: FileContent['encryptionHeader']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
key: FileContent['key']
|
||||
},
|
||||
private crypto: PureCryptoInterface,
|
||||
) {
|
||||
this.decryptor = this.crypto.xchacha20StreamInitDecryptor(this.file.encryptionHeader, this.file.key)
|
||||
}
|
||||
|
||||
public decryptBytes(encryptedBytes: Uint8Array): { decryptedBytes: Uint8Array; isFinalChunk: boolean } | undefined {
|
||||
const result = this.crypto.xchacha20StreamDecryptorPush(this.decryptor, encryptedBytes, this.file.remoteIdentifier)
|
||||
|
||||
if (result === false) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isFinal = result.tag === SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL
|
||||
|
||||
return { decryptedBytes: result.message, isFinalChunk: isFinal }
|
||||
}
|
||||
}
|
||||
58
packages/files/src/Domain/UseCase/FileDownloader.spec.ts
Normal file
58
packages/files/src/Domain/UseCase/FileDownloader.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { FileDownloader } from './FileDownloader'
|
||||
|
||||
describe('file downloader', () => {
|
||||
let apiService: FilesApiInterface
|
||||
let downloader: FileDownloader
|
||||
let file: {
|
||||
encryptedChunkSizes: FileContent['encryptedChunkSizes']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
}
|
||||
|
||||
const numChunks = 5
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {} as jest.Mocked<FilesApiInterface>
|
||||
apiService.createFileValetToken = jest.fn()
|
||||
apiService.downloadFile = jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
_chunkIndex: number,
|
||||
_apiToken: string,
|
||||
_rangeStart: number,
|
||||
onBytesReceived: (bytes: Uint8Array) => void,
|
||||
) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
onBytesReceived(Uint8Array.from([0xaa]))
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
file = {
|
||||
encryptedChunkSizes: [100_000],
|
||||
remoteIdentifier: '123',
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass back bytes as they are received', async () => {
|
||||
let receivedBytes = new Uint8Array()
|
||||
|
||||
downloader = new FileDownloader(file, apiService)
|
||||
|
||||
expect(receivedBytes.length).toBe(0)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
await downloader.run(async (encryptedBytes) => {
|
||||
receivedBytes = new Uint8Array([...receivedBytes, ...encryptedBytes])
|
||||
})
|
||||
|
||||
expect(receivedBytes.length).toEqual(numChunks)
|
||||
})
|
||||
})
|
||||
83
packages/files/src/Domain/UseCase/FileDownloader.ts
Normal file
83
packages/files/src/Domain/UseCase/FileDownloader.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { Deferred } from '@standardnotes/utils'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
export type AbortSignal = 'aborted'
|
||||
export type AbortFunction = () => void
|
||||
type OnEncryptedBytes = (
|
||||
encryptedBytes: Uint8Array,
|
||||
progress: FileDownloadProgress,
|
||||
abort: AbortFunction,
|
||||
) => Promise<void>
|
||||
|
||||
export type FileDownloaderResult = ClientDisplayableError | AbortSignal | undefined
|
||||
|
||||
export class FileDownloader {
|
||||
private aborted = false
|
||||
private abortDeferred = Deferred<AbortSignal>()
|
||||
private totalBytesDownloaded = 0
|
||||
|
||||
constructor(
|
||||
private file: {
|
||||
encryptedChunkSizes: FileContent['encryptedChunkSizes']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
},
|
||||
private readonly api: FilesApiInterface,
|
||||
) {}
|
||||
|
||||
private getProgress(): FileDownloadProgress {
|
||||
const encryptedSize = this.file.encryptedChunkSizes.reduce((total, chunk) => total + chunk, 0)
|
||||
|
||||
return {
|
||||
encryptedFileSize: encryptedSize,
|
||||
encryptedBytesDownloaded: this.totalBytesDownloaded,
|
||||
encryptedBytesRemaining: encryptedSize - this.totalBytesDownloaded,
|
||||
percentComplete: (this.totalBytesDownloaded / encryptedSize) * 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
public async run(onEncryptedBytes: OnEncryptedBytes): Promise<FileDownloaderResult> {
|
||||
const tokenResult = await this.getValetToken()
|
||||
|
||||
if (tokenResult instanceof ClientDisplayableError) {
|
||||
return tokenResult
|
||||
}
|
||||
|
||||
return this.performDownload(tokenResult, onEncryptedBytes)
|
||||
}
|
||||
|
||||
private async getValetToken(): Promise<string | ClientDisplayableError> {
|
||||
const tokenResult = await this.api.createFileValetToken(this.file.remoteIdentifier, 'read')
|
||||
|
||||
return tokenResult
|
||||
}
|
||||
|
||||
private async performDownload(valetToken: string, onEncryptedBytes: OnEncryptedBytes): Promise<FileDownloaderResult> {
|
||||
const chunkIndex = 0
|
||||
const startRange = 0
|
||||
|
||||
const onRemoteBytesReceived = async (bytes: Uint8Array) => {
|
||||
if (this.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.totalBytesDownloaded += bytes.byteLength
|
||||
|
||||
await onEncryptedBytes(bytes, this.getProgress(), this.abort)
|
||||
}
|
||||
|
||||
const downloadPromise = this.api.downloadFile(this.file, chunkIndex, valetToken, startRange, onRemoteBytesReceived)
|
||||
|
||||
const result = await Promise.race([this.abortDeferred.promise, downloadPromise])
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public abort(): void {
|
||||
this.aborted = true
|
||||
|
||||
this.abortDeferred.resolve('aborted')
|
||||
}
|
||||
}
|
||||
65
packages/files/src/Domain/UseCase/FileEncryptor.spec.ts
Normal file
65
packages/files/src/Domain/UseCase/FileEncryptor.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { PureCryptoInterface, StreamEncryptor, SodiumConstant } from '@standardnotes/sncrypto-common'
|
||||
import { FileEncryptor } from './FileEncryptor'
|
||||
|
||||
describe('file encryptor', () => {
|
||||
let encryptor: FileEncryptor
|
||||
let file: { key: FileContent['key']; remoteIdentifier: FileContent['remoteIdentifier'] }
|
||||
let crypto: PureCryptoInterface
|
||||
|
||||
beforeEach(() => {
|
||||
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',
|
||||
}
|
||||
|
||||
encryptor = new FileEncryptor(file, crypto)
|
||||
})
|
||||
|
||||
it('should initialize header', () => {
|
||||
const header = encryptor.initializeHeader()
|
||||
|
||||
expect(header.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('pushBytes should return encrypted bytes', () => {
|
||||
encryptor.initializeHeader()
|
||||
const encryptedBytes = encryptor.pushBytes(new Uint8Array(), false)
|
||||
|
||||
expect(encryptedBytes).toBeInstanceOf(Uint8Array)
|
||||
})
|
||||
|
||||
it('pushBytes with last chunk should pass final tag', () => {
|
||||
encryptor.initializeHeader()
|
||||
const decryptedBytes = new Uint8Array()
|
||||
encryptor.pushBytes(decryptedBytes, true)
|
||||
|
||||
expect(crypto.xchacha20StreamEncryptorPush).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
decryptedBytes,
|
||||
file.remoteIdentifier,
|
||||
SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL,
|
||||
)
|
||||
})
|
||||
|
||||
it('pushBytes with not last chunk should not pass final tag', () => {
|
||||
encryptor.initializeHeader()
|
||||
const decryptedBytes = new Uint8Array()
|
||||
encryptor.pushBytes(decryptedBytes, false)
|
||||
|
||||
expect(crypto.xchacha20StreamEncryptorPush).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
decryptedBytes,
|
||||
file.remoteIdentifier,
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
34
packages/files/src/Domain/UseCase/FileEncryptor.ts
Normal file
34
packages/files/src/Domain/UseCase/FileEncryptor.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { PureCryptoInterface, StreamEncryptor, SodiumConstant } from '@standardnotes/sncrypto-common'
|
||||
|
||||
export class FileEncryptor {
|
||||
private stream!: StreamEncryptor
|
||||
|
||||
constructor(
|
||||
private readonly file: { key: FileContent['key']; remoteIdentifier: FileContent['remoteIdentifier'] },
|
||||
private crypto: PureCryptoInterface,
|
||||
) {}
|
||||
|
||||
public initializeHeader(): string {
|
||||
this.stream = this.crypto.xchacha20StreamInitEncryptor(this.file.key)
|
||||
|
||||
return this.stream.header
|
||||
}
|
||||
|
||||
public pushBytes(decryptedBytes: Uint8Array, isFinalChunk: boolean): Uint8Array {
|
||||
if (!this.stream) {
|
||||
throw new Error('FileEncryptor must call initializeHeader first')
|
||||
}
|
||||
|
||||
const tag = isFinalChunk ? SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL : undefined
|
||||
|
||||
const encryptedBytes = this.crypto.xchacha20StreamEncryptorPush(
|
||||
this.stream,
|
||||
decryptedBytes,
|
||||
this.file.remoteIdentifier,
|
||||
tag,
|
||||
)
|
||||
|
||||
return encryptedBytes
|
||||
}
|
||||
}
|
||||
21
packages/files/src/Domain/UseCase/FileUploader.spec.ts
Normal file
21
packages/files/src/Domain/UseCase/FileUploader.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { FileUploader } from './FileUploader'
|
||||
|
||||
describe('file uploader', () => {
|
||||
let apiService
|
||||
let uploader: FileUploader
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {} as jest.Mocked<FilesApiInterface>
|
||||
apiService.uploadFileBytes = jest.fn().mockReturnValue(true)
|
||||
|
||||
uploader = new FileUploader(apiService)
|
||||
})
|
||||
|
||||
it('should return true when a chunk is uploaded', async () => {
|
||||
const bytes = new Uint8Array()
|
||||
const success = await uploader.uploadBytes(bytes, 2, 'api-token')
|
||||
|
||||
expect(success).toEqual(true)
|
||||
})
|
||||
})
|
||||
11
packages/files/src/Domain/UseCase/FileUploader.ts
Normal file
11
packages/files/src/Domain/UseCase/FileUploader.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
|
||||
export class FileUploader {
|
||||
constructor(private apiService: FilesApiInterface) {}
|
||||
|
||||
public async uploadBytes(encryptedBytes: Uint8Array, chunkId: number, apiToken: string): Promise<boolean> {
|
||||
const result = await this.apiService.uploadFileBytes(apiToken, chunkId, encryptedBytes)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
12
packages/files/src/Domain/index.ts
Normal file
12
packages/files/src/Domain/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './Service/FileService'
|
||||
export * from './Service/FilesClientInterface'
|
||||
export * from './Operations/DownloadAndDecrypt'
|
||||
export * from './Operations/EncryptAndUpload'
|
||||
export * from './UseCase/FileDecryptor'
|
||||
export * from './UseCase/FileUploader'
|
||||
export * from './UseCase/FileEncryptor'
|
||||
export * from './UseCase/FileDownloader'
|
||||
export * from './Types/FileDownloadProgress'
|
||||
export * from './Types/FileUploadProgress'
|
||||
export * from './Types/FileUploadResult'
|
||||
export * from './Backups/BackupService'
|
||||
1
packages/files/src/index.ts
Normal file
1
packages/files/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Domain'
|
||||
Reference in New Issue
Block a user