From 1cd8a47fa4301b1078bdf357d81c40a5c3c3a25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Tue, 5 Jul 2022 20:35:19 +0200 Subject: [PATCH] feat: add files package --- .gitignore | 1 + ...files-npm-1.3.22-85a573b022-4bd58c1aed.zip | Bin 34479 -> 0 bytes ...files-npm-1.3.23-e16ad43eb0-923dbd892e.zip | Bin 34492 -> 0 bytes packages/files/.eslintignore | 2 + packages/files/.eslintrc | 6 + packages/files/CHANGELOG.md | 256 ++++++++++++++ packages/files/jest.config.js | 11 + packages/files/linter.tsconfig.json | 4 + packages/files/package.json | 45 +++ .../files/src/Domain/Backups/BackupService.ts | 173 ++++++++++ .../Operations/DownloadAndDecrypt.spec.ts | 113 +++++++ .../Domain/Operations/DownloadAndDecrypt.ts | 75 +++++ .../Operations/EncryptAndUpload.spec.ts | 68 ++++ .../src/Domain/Operations/EncryptAndUpload.ts | 86 +++++ .../src/Domain/Service/FileService.spec.ts | 121 +++++++ .../files/src/Domain/Service/FileService.ts | 314 ++++++++++++++++++ .../Domain/Service/FilesClientInterface.ts | 48 +++ .../Service/ReadAndDecryptBackupFile.ts | 36 ++ .../src/Domain/Types/FileDownloadProgress.ts | 6 + .../src/Domain/Types/FileUploadProgress.ts | 6 + .../src/Domain/Types/FileUploadResult.ts | 6 + .../src/Domain/UseCase/FileDecryptor.spec.ts | 51 +++ .../files/src/Domain/UseCase/FileDecryptor.ts | 29 ++ .../src/Domain/UseCase/FileDownloader.spec.ts | 58 ++++ .../src/Domain/UseCase/FileDownloader.ts | 83 +++++ .../src/Domain/UseCase/FileEncryptor.spec.ts | 65 ++++ .../files/src/Domain/UseCase/FileEncryptor.ts | 34 ++ .../src/Domain/UseCase/FileUploader.spec.ts | 21 ++ .../files/src/Domain/UseCase/FileUploader.ts | 11 + packages/files/src/Domain/index.ts | 12 + packages/files/src/index.ts | 1 + packages/files/tsconfig.json | 13 + yarn.lock | 39 +-- 33 files changed, 1771 insertions(+), 23 deletions(-) delete mode 100644 .yarn/cache/@standardnotes-files-npm-1.3.22-85a573b022-4bd58c1aed.zip delete mode 100644 .yarn/cache/@standardnotes-files-npm-1.3.23-e16ad43eb0-923dbd892e.zip create mode 100644 packages/files/.eslintignore create mode 100644 packages/files/.eslintrc create mode 100644 packages/files/CHANGELOG.md create mode 100644 packages/files/jest.config.js create mode 100644 packages/files/linter.tsconfig.json create mode 100644 packages/files/package.json create mode 100644 packages/files/src/Domain/Backups/BackupService.ts create mode 100644 packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts create mode 100644 packages/files/src/Domain/Operations/DownloadAndDecrypt.ts create mode 100644 packages/files/src/Domain/Operations/EncryptAndUpload.spec.ts create mode 100644 packages/files/src/Domain/Operations/EncryptAndUpload.ts create mode 100644 packages/files/src/Domain/Service/FileService.spec.ts create mode 100644 packages/files/src/Domain/Service/FileService.ts create mode 100644 packages/files/src/Domain/Service/FilesClientInterface.ts create mode 100644 packages/files/src/Domain/Service/ReadAndDecryptBackupFile.ts create mode 100644 packages/files/src/Domain/Types/FileDownloadProgress.ts create mode 100644 packages/files/src/Domain/Types/FileUploadProgress.ts create mode 100644 packages/files/src/Domain/Types/FileUploadResult.ts create mode 100644 packages/files/src/Domain/UseCase/FileDecryptor.spec.ts create mode 100644 packages/files/src/Domain/UseCase/FileDecryptor.ts create mode 100644 packages/files/src/Domain/UseCase/FileDownloader.spec.ts create mode 100644 packages/files/src/Domain/UseCase/FileDownloader.ts create mode 100644 packages/files/src/Domain/UseCase/FileEncryptor.spec.ts create mode 100644 packages/files/src/Domain/UseCase/FileEncryptor.ts create mode 100644 packages/files/src/Domain/UseCase/FileUploader.spec.ts create mode 100644 packages/files/src/Domain/UseCase/FileUploader.ts create mode 100644 packages/files/src/Domain/index.ts create mode 100644 packages/files/src/index.ts create mode 100644 packages/files/tsconfig.json diff --git a/.gitignore b/.gitignore index 5cf886dec..fab97a001 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ packages/web/dist packages/filepicker/dist packages/features/dist packages/encryption/dist +packages/files/dist **/.pnp.* **/.yarn/* diff --git a/.yarn/cache/@standardnotes-files-npm-1.3.22-85a573b022-4bd58c1aed.zip b/.yarn/cache/@standardnotes-files-npm-1.3.22-85a573b022-4bd58c1aed.zip deleted file mode 100644 index 8842e412c6c82d1f23dd936530a975acfdb81699..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34479 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 diff --git a/.yarn/cache/@standardnotes-files-npm-1.3.23-e16ad43eb0-923dbd892e.zip b/.yarn/cache/@standardnotes-files-npm-1.3.23-e16ad43eb0-923dbd892e.zip deleted file mode 100644 index d7fa62577faf89e15758e8f55ddc74f802b1afd3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34492 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 diff --git a/packages/files/.eslintignore b/packages/files/.eslintignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/files/.eslintignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/files/.eslintrc b/packages/files/.eslintrc new file mode 100644 index 000000000..cb7136174 --- /dev/null +++ b/packages/files/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "../../.eslintrc", + "parserOptions": { + "project": "./linter.tsconfig.json" + } +} diff --git a/packages/files/CHANGELOG.md b/packages/files/CHANGELOG.md new file mode 100644 index 000000000..527f5c976 --- /dev/null +++ b/packages/files/CHANGELOG.md @@ -0,0 +1,256 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.4.1](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.4.0...@standardnotes/files@1.4.1) (2022-07-05) + +**Note:** Version bump only for package @standardnotes/files + +# [1.4.0](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.24...@standardnotes/files@1.4.0) (2022-07-05) + +### Features + +* remove encryption package in favor of standardnotes/app repository ([f6d1c9e](https://github.com/standardnotes/snjs/commit/f6d1c9ee538bb59ee7ac28c0d49ca682d4eb4d38)) + +## [1.3.24](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.23...@standardnotes/files@1.3.24) (2022-07-04) + +### Bug Fixes + +* add missing reflect-metadata package to all packages ([ce3a5bb](https://github.com/standardnotes/snjs/commit/ce3a5bbf3f1d2276ac4abc3eec3c6a44c8c3ba9b)) + +## [1.3.23](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.22...@standardnotes/files@1.3.23) (2022-06-29) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.22](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.21...@standardnotes/files@1.3.22) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.21](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.20...@standardnotes/files@1.3.21) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.20](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.19...@standardnotes/files@1.3.20) (2022-06-22) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.19](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.18...@standardnotes/files@1.3.19) (2022-06-20) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.18](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.17...@standardnotes/files@1.3.18) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.17](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.16...@standardnotes/files@1.3.17) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.16](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.15...@standardnotes/files@1.3.16) (2022-06-15) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.15](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.14...@standardnotes/files@1.3.15) (2022-06-10) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.14](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.13...@standardnotes/files@1.3.14) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.13](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.12...@standardnotes/files@1.3.13) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.12](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.11...@standardnotes/files@1.3.12) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.11](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.10...@standardnotes/files@1.3.11) (2022-06-06) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.10](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.9...@standardnotes/files@1.3.10) (2022-06-03) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.9](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.8...@standardnotes/files@1.3.9) (2022-06-02) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.8](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.7...@standardnotes/files@1.3.8) (2022-06-02) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.7](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.6...@standardnotes/files@1.3.7) (2022-06-02) + +### Bug Fixes + +* remove isLast dep from ordered byte chunker ([3385581](https://github.com/standardnotes/snjs/commit/33855817d8d96d100b7d4f423f59f00c55834b6f)) + +## [1.3.6](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.5...@standardnotes/files@1.3.6) (2022-06-01) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.5](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.4...@standardnotes/files@1.3.5) (2022-05-30) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.4](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.3...@standardnotes/files@1.3.4) (2022-05-27) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.3](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.2...@standardnotes/files@1.3.3) (2022-05-27) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.2](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.1...@standardnotes/files@1.3.2) (2022-05-24) + +**Note:** Version bump only for package @standardnotes/files + +## [1.3.1](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.3.0...@standardnotes/files@1.3.1) (2022-05-24) + +**Note:** Version bump only for package @standardnotes/files + +# [1.3.0](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.2.1...@standardnotes/files@1.3.0) (2022-05-23) + +### Features + +* encrypted file cache ([#747](https://github.com/standardnotes/snjs/issues/747)) ([5b156a5](https://github.com/standardnotes/snjs/commit/5b156a5b4ee3365dac8e02653df129584a9dd4ef)) + +## [1.2.1](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.2.0...@standardnotes/files@1.2.1) (2022-05-22) + +**Note:** Version bump only for package @standardnotes/files + +# [1.2.0](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.13...@standardnotes/files@1.2.0) (2022-05-21) + +### Features + +* display controllers ([#743](https://github.com/standardnotes/snjs/issues/743)) ([5fadce3](https://github.com/standardnotes/snjs/commit/5fadce37f1b3f2f51b8a90c257bc666ac7710074)) + +## [1.1.13](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.12...@standardnotes/files@1.1.13) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.12](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.11...@standardnotes/files@1.1.12) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.11](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.10...@standardnotes/files@1.1.11) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.10](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.9...@standardnotes/files@1.1.10) (2022-05-18) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.9](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.8...@standardnotes/files@1.1.9) (2022-05-17) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.8](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.7...@standardnotes/files@1.1.8) (2022-05-17) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.7](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.6...@standardnotes/files@1.1.7) (2022-05-17) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.6](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.5...@standardnotes/files@1.1.6) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.5](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.4...@standardnotes/files@1.1.5) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.4](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.3...@standardnotes/files@1.1.4) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.3](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.2...@standardnotes/files@1.1.3) (2022-05-13) + +**Note:** Version bump only for package @standardnotes/files + +## [1.1.2](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.1...@standardnotes/files@1.1.2) (2022-05-13) + +### Bug Fixes + +* file backup decryption ([3f0d076](https://github.com/standardnotes/snjs/commit/3f0d076434c0dbb1827a298e302e4dc3815e501c)) + +## [1.1.1](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.1.0...@standardnotes/files@1.1.1) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/files + +# [1.1.0](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.15...@standardnotes/files@1.1.0) (2022-05-12) + +### Features + +* file desktop backups ([#731](https://github.com/standardnotes/snjs/issues/731)) ([0dbce7d](https://github.com/standardnotes/snjs/commit/0dbce7dc9712fde848445b951079c81479c8bc11)) + +## [1.0.15](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.14...@standardnotes/files@1.0.15) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.14](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.13...@standardnotes/files@1.0.14) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.13](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.12...@standardnotes/files@1.0.13) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.12](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.11...@standardnotes/files@1.0.12) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.11](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.10...@standardnotes/files@1.0.11) (2022-05-06) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.10](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.9...@standardnotes/files@1.0.10) (2022-05-06) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.9](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.8...@standardnotes/files@1.0.9) (2022-05-05) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.8](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.6...@standardnotes/files@1.0.8) (2022-05-04) + +### Bug Fixes + +* config package missing dependencies ([3dec12f](https://github.com/standardnotes/snjs/commit/3dec12fa4a83a8aed8419819eafb7c34795cb09f)) + +## [1.0.7](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.6...@standardnotes/files@1.0.7) (2022-05-04) + +### Bug Fixes + +* config package missing dependencies ([3dec12f](https://github.com/standardnotes/snjs/commit/3dec12fa4a83a8aed8419819eafb7c34795cb09f)) + +## [1.0.6](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.5...@standardnotes/files@1.0.6) (2022-05-03) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.5](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.4...@standardnotes/files@1.0.5) (2022-05-02) + +### Bug Fixes + +* expose download progress ([#721](https://github.com/standardnotes/snjs/issues/721)) ([db7d205](https://github.com/standardnotes/snjs/commit/db7d20516ab918723b1a1d07e664d6f2ccccab75)) + +## [1.0.4](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.3...@standardnotes/files@1.0.4) (2022-05-02) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.3](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.2...@standardnotes/files@1.0.3) (2022-04-29) + +**Note:** Version bump only for package @standardnotes/files + +## [1.0.2](https://github.com/standardnotes/snjs/compare/@standardnotes/files@1.0.1...@standardnotes/files@1.0.2) (2022-04-28) + +**Note:** Version bump only for package @standardnotes/files + +## 1.0.1 (2022-04-28) + +**Note:** Version bump only for package @standardnotes/files diff --git a/packages/files/jest.config.js b/packages/files/jest.config.js new file mode 100644 index 000000000..ad1ceabb0 --- /dev/null +++ b/packages/files/jest.config.js @@ -0,0 +1,11 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const base = require('../../node_modules/@standardnotes/config/src/jest.json'); + +module.exports = { + ...base, + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.json', + }, + } +}; diff --git a/packages/files/linter.tsconfig.json b/packages/files/linter.tsconfig.json new file mode 100644 index 000000000..c1a7d22c5 --- /dev/null +++ b/packages/files/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist"] +} diff --git a/packages/files/package.json b/packages/files/package.json new file mode 100644 index 000000000..c61b1865f --- /dev/null +++ b/packages/files/package.json @@ -0,0 +1,45 @@ +{ + "name": "@standardnotes/files", + "version": "1.5.0", + "engines": { + "node": ">=16.0.0 <17.0.0" + }, + "description": "Client-side files library", + "main": "dist/index.js", + "author": "Standard Notes", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "license": "AGPL-3.0-or-later", + "scripts": { + "clean": "rm -fr dist", + "prestart": "yarn clean", + "start": "tsc -p tsconfig.json --watch", + "prebuild": "yarn clean", + "build": "tsc -p tsconfig.json", + "lint": "eslint . --ext .ts", + "test:unit": "jest" + }, + "devDependencies": { + "@types/jest": "^27.4.1", + "@typescript-eslint/eslint-plugin": "^5.30.0", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^27.5.1", + "ts-jest": "^27.1.3" + }, + "dependencies": { + "@standardnotes/common": "^1.23.1", + "@standardnotes/encryption": "workspace:*", + "@standardnotes/filepicker": "workspace:*", + "@standardnotes/models": "^1.11.13", + "@standardnotes/responses": "^1.6.39", + "@standardnotes/services": "^1.13.23", + "@standardnotes/sncrypto-common": "^1.9.0", + "@standardnotes/utils": "^1.6.12", + "reflect-metadata": "^0.1.13" + } +} diff --git a/packages/files/src/Domain/Backups/BackupService.ts b/packages/files/src/Domain/Backups/BackupService.ts new file mode 100644 index 000000000..f557171f0 --- /dev/null +++ b/packages/files/src/Domain/Backups/BackupService.ts @@ -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() + + 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(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 { + return this.device.isFilesBackupsEnabled() + } + + public async enableFilesBackups(): Promise { + await this.device.enableFilesBackups() + + if (!(await this.isFilesBackupsEnabled())) { + return + } + + this.backupAllFiles() + } + + private backupAllFiles(): void { + const files = this.items.getItems(ContentType.File) + + void this.handleChangedFiles(files) + } + + public disableFilesBackups(): Promise { + return this.device.disableFilesBackups() + } + + public changeFilesBackupsLocation(): Promise { + return this.device.changeFilesBackupsLocation() + } + + public getFilesBackupsLocation(): Promise { + return this.device.getFilesBackupsLocation() + } + + public openFilesBackupsLocation(): Promise { + return this.device.openFilesBackupsLocation() + } + + private async getBackupsMapping(): Promise { + return (await this.device.getFilesBackupsMappingFile()).files + } + + private async handleChangedFiles(files: FileItem[]): Promise { + 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 + } +} diff --git a/packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts b/packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts new file mode 100644 index 000000000..c0e4f79f0 --- /dev/null +++ b/packages/files/src/Domain/Operations/DownloadAndDecrypt.spec.ts @@ -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((resolve) => { + void receiveFile().then(resolve) + }) + }, + ) + } + + beforeEach(() => { + apiService = {} as jest.Mocked + apiService.createFileValetToken = jest.fn() + downloadChunksOfSize(5) + + crypto = {} as jest.Mocked + + 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) + }) +}) diff --git a/packages/files/src/Domain/Operations/DownloadAndDecrypt.ts b/packages/files/src/Domain/Operations/DownloadAndDecrypt.ts new file mode 100644 index 000000000..a02c1b4aa --- /dev/null +++ b/packages/files/src/Domain/Operations/DownloadAndDecrypt.ts @@ -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 + +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 { + 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() + } +} diff --git a/packages/files/src/Domain/Operations/EncryptAndUpload.spec.ts b/packages/files/src/Domain/Operations/EncryptAndUpload.spec.ts new file mode 100644 index 000000000..be7322b8c --- /dev/null +++ b/packages/files/src/Domain/Operations/EncryptAndUpload.spec.ts @@ -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 + apiService.uploadFileBytes = jest.fn().mockReturnValue(true) + + crypto = {} as jest.Mocked + + 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) + }) +}) diff --git a/packages/files/src/Domain/Operations/EncryptAndUpload.ts b/packages/files/src/Domain/Operations/EncryptAndUpload.ts new file mode 100644 index 000000000..4d7a64d16 --- /dev/null +++ b/packages/files/src/Domain/Operations/EncryptAndUpload.ts @@ -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 { + 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 { + const success = await this.uploader.uploadBytes(encryptedBytes, chunkId, this.apiToken) + + return success + } +} diff --git a/packages/files/src/Domain/Service/FileService.spec.ts b/packages/files/src/Domain/Service/FileService.spec.ts new file mode 100644 index 000000000..ac924f0ed --- /dev/null +++ b/packages/files/src/Domain/Service/FileService.spec.ts @@ -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 + apiService.addEventObserver = jest.fn() + apiService.createFileValetToken = jest.fn() + apiService.downloadFile = jest.fn() + apiService.deleteFile = jest.fn().mockReturnValue({}) + + itemManager = {} as jest.Mocked + 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 + + syncService = {} as jest.Mocked + syncService.sync = jest.fn() + + encryptor = {} as jest.Mocked + + alertService = {} as jest.Mocked + alertService.confirm = jest.fn().mockReturnValue(true) + alertService.alert = jest.fn() + + crypto = {} as jest.Mocked + crypto.base64Decode = jest.fn() + internalEventBus = {} as jest.Mocked + 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 + + 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 + + await fileService.downloadFile(file, async () => { + return Promise.resolve() + }) + + await fileService.deleteFile(file) + + expect(fileService['encryptedCache'].get(file.uuid)).toBeFalsy() + }) +}) diff --git a/packages/files/src/Domain/Service/FileService.ts b/packages/files/src/Domain/Service/FileService.ts new file mode 100644 index 000000000..0bc28b09e --- /dev/null +++ b/packages/files/src/Domain/Service/FileService.ts @@ -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 { + 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 { + 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 { + 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( + ContentType.File, + FillItemContentSpecialized(fileContent), + true, + ) + + await this.syncService.sync() + + return file + } + + private async decryptCachedEntry(file: FileItem, entry: EncryptedBytes): Promise { + 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, + ): Promise { + 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 => { + 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 { + 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 { + 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({ + usesItemsKey: { + items: [encryptedFile], + key: itemsKey, + }, + }) + + if (isEncryptedPayload(decryptedFile)) { + return undefined + } + + return new FileItem(decryptedFile) + } + + public async selectFile(fileSystem: FileSystemApi): Promise { + 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 { + let bytes = new Uint8Array() + + await readAndDecryptBackupFile(fileHandle, file, fileSystem, this.crypto, async (decryptedBytes) => { + bytes = new Uint8Array([...bytes, ...decryptedBytes]) + }) + + return bytes + } +} diff --git a/packages/files/src/Domain/Service/FilesClientInterface.ts b/packages/files/src/Domain/Service/FilesClientInterface.ts new file mode 100644 index 000000000..b9605aec1 --- /dev/null +++ b/packages/files/src/Domain/Service/FilesClientInterface.ts @@ -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 + + pushBytesForUpload( + operation: EncryptAndUploadFileOperation, + bytes: Uint8Array, + chunkId: number, + isFinalChunk: boolean, + ): Promise + + finishUpload( + operation: EncryptAndUploadFileOperation, + fileMetadata: FileMetadata, + ): Promise + + downloadFile( + file: FileItem, + onDecryptedBytes: (bytes: Uint8Array, progress: FileDownloadProgress | undefined) => Promise, + ): Promise + + deleteFile(file: FileItem): Promise + + minimumChunkSize(): number + + isFileNameFileBackupRelated(name: string): 'metadata' | 'binary' | false + + decryptBackupMetadataFile(metdataFile: FileBackupMetadataFile): Promise + + selectFile(fileSystem: FileSystemApi): Promise + + readBackupFileAndSaveDecrypted( + fileHandle: FileHandleRead, + file: FileItem, + fileSystem: FileSystemApi, + ): Promise<'success' | 'aborted' | 'failed'> + + readBackupFileBytesDecrypted( + fileHandle: FileHandleRead, + file: FileItem, + fileSystem: FileSystemApi, + ): Promise +} diff --git a/packages/files/src/Domain/Service/ReadAndDecryptBackupFile.ts b/packages/files/src/Domain/Service/ReadAndDecryptBackupFile.ts new file mode 100644 index 000000000..a0f889279 --- /dev/null +++ b/packages/files/src/Domain/Service/ReadAndDecryptBackupFile.ts @@ -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, +): 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 +} diff --git a/packages/files/src/Domain/Types/FileDownloadProgress.ts b/packages/files/src/Domain/Types/FileDownloadProgress.ts new file mode 100644 index 000000000..eac0067fb --- /dev/null +++ b/packages/files/src/Domain/Types/FileDownloadProgress.ts @@ -0,0 +1,6 @@ +export type FileDownloadProgress = { + encryptedFileSize: number + encryptedBytesDownloaded: number + encryptedBytesRemaining: number + percentComplete: number +} diff --git a/packages/files/src/Domain/Types/FileUploadProgress.ts b/packages/files/src/Domain/Types/FileUploadProgress.ts new file mode 100644 index 000000000..c90507799 --- /dev/null +++ b/packages/files/src/Domain/Types/FileUploadProgress.ts @@ -0,0 +1,6 @@ +export type FileUploadProgress = { + decryptedFileSize: number + decryptedBytesUploaded: number + decryptedBytesRemaining: number + percentComplete: number +} diff --git a/packages/files/src/Domain/Types/FileUploadResult.ts b/packages/files/src/Domain/Types/FileUploadResult.ts new file mode 100644 index 000000000..b8d22f638 --- /dev/null +++ b/packages/files/src/Domain/Types/FileUploadResult.ts @@ -0,0 +1,6 @@ +export type FileUploadResult = { + encryptionHeader: string + finalDecryptedSize: number + key: string + remoteIdentifier: string +} diff --git a/packages/files/src/Domain/UseCase/FileDecryptor.spec.ts b/packages/files/src/Domain/UseCase/FileDecryptor.spec.ts new file mode 100644 index 000000000..05d6c7fcf --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileDecryptor.spec.ts @@ -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 + + 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) + }) +}) diff --git a/packages/files/src/Domain/UseCase/FileDecryptor.ts b/packages/files/src/Domain/UseCase/FileDecryptor.ts new file mode 100644 index 000000000..c5dd6baa5 --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileDecryptor.ts @@ -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 } + } +} diff --git a/packages/files/src/Domain/UseCase/FileDownloader.spec.ts b/packages/files/src/Domain/UseCase/FileDownloader.spec.ts new file mode 100644 index 000000000..8e90dabd9 --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileDownloader.spec.ts @@ -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 + apiService.createFileValetToken = jest.fn() + apiService.downloadFile = jest + .fn() + .mockImplementation( + ( + _file: string, + _chunkIndex: number, + _apiToken: string, + _rangeStart: number, + onBytesReceived: (bytes: Uint8Array) => void, + ) => { + return new Promise((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) + }) +}) diff --git a/packages/files/src/Domain/UseCase/FileDownloader.ts b/packages/files/src/Domain/UseCase/FileDownloader.ts new file mode 100644 index 000000000..8c60b6f5b --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileDownloader.ts @@ -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 + +export type FileDownloaderResult = ClientDisplayableError | AbortSignal | undefined + +export class FileDownloader { + private aborted = false + private abortDeferred = Deferred() + 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 { + const tokenResult = await this.getValetToken() + + if (tokenResult instanceof ClientDisplayableError) { + return tokenResult + } + + return this.performDownload(tokenResult, onEncryptedBytes) + } + + private async getValetToken(): Promise { + const tokenResult = await this.api.createFileValetToken(this.file.remoteIdentifier, 'read') + + return tokenResult + } + + private async performDownload(valetToken: string, onEncryptedBytes: OnEncryptedBytes): Promise { + 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') + } +} diff --git a/packages/files/src/Domain/UseCase/FileEncryptor.spec.ts b/packages/files/src/Domain/UseCase/FileEncryptor.spec.ts new file mode 100644 index 000000000..5d8472b09 --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileEncryptor.spec.ts @@ -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 + 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, + ) + }) +}) diff --git a/packages/files/src/Domain/UseCase/FileEncryptor.ts b/packages/files/src/Domain/UseCase/FileEncryptor.ts new file mode 100644 index 000000000..9906bf724 --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileEncryptor.ts @@ -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 + } +} diff --git a/packages/files/src/Domain/UseCase/FileUploader.spec.ts b/packages/files/src/Domain/UseCase/FileUploader.spec.ts new file mode 100644 index 000000000..54e3102b5 --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileUploader.spec.ts @@ -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 + 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) + }) +}) diff --git a/packages/files/src/Domain/UseCase/FileUploader.ts b/packages/files/src/Domain/UseCase/FileUploader.ts new file mode 100644 index 000000000..a6f0dc1c9 --- /dev/null +++ b/packages/files/src/Domain/UseCase/FileUploader.ts @@ -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 { + const result = await this.apiService.uploadFileBytes(apiToken, chunkId, encryptedBytes) + + return result + } +} diff --git a/packages/files/src/Domain/index.ts b/packages/files/src/Domain/index.ts new file mode 100644 index 000000000..e0cffa120 --- /dev/null +++ b/packages/files/src/Domain/index.ts @@ -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' diff --git a/packages/files/src/index.ts b/packages/files/src/index.ts new file mode 100644 index 000000000..920deacdb --- /dev/null +++ b/packages/files/src/index.ts @@ -0,0 +1 @@ +export * from './Domain' diff --git a/packages/files/tsconfig.json b/packages/files/tsconfig.json new file mode 100644 index 000000000..f3dac14ef --- /dev/null +++ b/packages/files/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../node_modules/@standardnotes/config/src/tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*" + ], + "references": [], + "exclude": ["**/*.spec.ts", "dist", "node_modules"] +} diff --git a/yarn.lock b/yarn.lock index 9433fedea..f08e58925 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6599,7 +6599,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/filepicker@^1.16.22, @standardnotes/filepicker@^1.16.23, @standardnotes/filepicker@workspace:*, @standardnotes/filepicker@workspace:packages/filepicker": +"@standardnotes/filepicker@workspace:*, @standardnotes/filepicker@workspace:packages/filepicker": version: 0.0.0-use.local resolution: "@standardnotes/filepicker@workspace:packages/filepicker" dependencies: @@ -6617,33 +6617,26 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/files@npm:^1.3.22": - version: 1.3.22 - resolution: "@standardnotes/files@npm:1.3.22" +"@standardnotes/files@^1.3.22, @standardnotes/files@^1.3.23, @standardnotes/files@workspace:packages/files": + version: 0.0.0-use.local + resolution: "@standardnotes/files@workspace:packages/files" dependencies: - "@standardnotes/encryption": ^1.8.22 - "@standardnotes/filepicker": ^1.16.22 - "@standardnotes/models": ^1.11.12 - "@standardnotes/responses": ^1.6.38 - "@standardnotes/services": ^1.13.22 - "@standardnotes/utils": ^1.6.12 - checksum: 4bd58c1aedf21892dceee7554b2f8003bca8aaa651079b0aad31bc57f160fadc455010241da1544e488c0f3c08e57549d610908b2c2f57b1d8c0991204ff2fb1 - languageName: node - linkType: hard - -"@standardnotes/files@npm:^1.3.23": - version: 1.3.23 - resolution: "@standardnotes/files@npm:1.3.23" - dependencies: - "@standardnotes/encryption": ^1.8.23 - "@standardnotes/filepicker": ^1.16.23 + "@standardnotes/common": ^1.23.1 + "@standardnotes/encryption": "workspace:*" + "@standardnotes/filepicker": "workspace:*" "@standardnotes/models": ^1.11.13 "@standardnotes/responses": ^1.6.39 "@standardnotes/services": ^1.13.23 + "@standardnotes/sncrypto-common": ^1.9.0 "@standardnotes/utils": ^1.6.12 - checksum: 923dbd892ebfe015f19a1fab0a46f0c00edecd89d92873be121fcb3b3ca71b0e9a6bd2bb69b421a5591adc7df19930ceb116f56be3dd45eca708cf0f05e1d05b - languageName: node - linkType: hard + "@types/jest": ^27.4.1 + "@typescript-eslint/eslint-plugin": ^5.30.0 + eslint-plugin-prettier: ^4.2.1 + jest: ^27.5.1 + reflect-metadata: ^0.1.13 + ts-jest: ^27.1.3 + languageName: unknown + linkType: soft "@standardnotes/filesafe-bar@workspace:packages/components/src/Packages/Deprecated/org.standardnotes.file-safe": version: 0.0.0-use.local